From 085bd282ecd340231d524dcb5a3c8d2003bcb475 Mon Sep 17 00:00:00 2001 From: Mihir-Mavalankar Date: Fri, 3 Apr 2026 08:58:44 -0700 Subject: [PATCH 01/35] feat(seer): Convert structured LLMContext JSON to markdown for on_page_context (#112181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + When the frontend sends a structured LLMContextSnapshot as JSON in on_page_context (instead of the legacy ASCII DOM screenshot), the explorer chat endpoint now detects it and converts it to a readable markdown string before forwarding to Seer. Non-JSON on_page_context (the existing ASCII screenshot) passes through unchanged. + This is the backend half of the structured page context feature. The frontend counterpart (conditionally sending JSON instead of ASCII when organizations:context-engine-structured-page-context flag is on + dashboards page) will follow in a separate PR per the frontend/backend split deploy requirement. + This won't do anything currently right cuz the this is always false - snapshot = json.loads(on_page_context) Follow-up PRs 1. Frontend PR: Wire getLLMContext() into useSeerExplorer.tsx — when the feature flag is on and the user is on a dashboards page, send JSON.stringify(getLLMContext()) as on_page_context instead of the ASCII screenshot 2. Widget/chart context nodes: Add registerLLMContext('widget', ...) and registerLLMContext('chart', ...) to dashboard widget and chart components so the snapshot tree has richer data (query config, filters, etc. — not raw timeseries data) Co-authored-by: Claude Opus 4.6 --- .../organization_seer_explorer_chat.py | 15 +++- src/sentry/seer/explorer/client_utils.py | 30 ++++++++ .../test_organization_seer_explorer_chat.py | 42 ++++++++++++ .../sentry/seer/explorer/test_client_utils.py | 68 +++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py index f4925560a747..a0ba5be568ce 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py @@ -15,10 +15,14 @@ from sentry.models.organization import Organization from sentry.ratelimits.config import RateLimitConfig from sentry.seer.explorer.client import SeerExplorerClient -from sentry.seer.explorer.client_utils import has_seer_explorer_access_with_detail +from sentry.seer.explorer.client_utils import ( + has_seer_explorer_access_with_detail, + snapshot_to_markdown, +) from sentry.seer.models import SeerPermissionError from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.types.ratelimit import RateLimit, RateLimitCategory +from sentry.utils import json logger = logging.getLogger(__name__) @@ -153,6 +157,15 @@ def post( page_name = validated_data.get("page_name") override_ce_enable = validated_data["override_ce_enable"] + # If the frontend sent a structured LLMContext JSON snapshot, convert to markdown. + if on_page_context: + try: + snapshot = json.loads(on_page_context) + if isinstance(snapshot, dict) and "nodes" in snapshot: + on_page_context = snapshot_to_markdown(snapshot) + except (json.JSONDecodeError, TypeError, AttributeError): + pass + try: enable_coding = organization.get_option( "sentry:enable_seer_coding", False diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 191a45771203..afed6f41fd5e 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -340,3 +340,33 @@ def poll_until_done( # Wait before next poll time.sleep(poll_interval) + + +def _render_node(node: dict[str, Any], depth: int) -> str: + """Recursively render an LLMContextSnapshot node and its children as markdown.""" + heading = "#" * min(depth + 1, 6) + lines = [f"{heading} {node.get('nodeType', 'unknown')}"] + + data = node.get("data") + if isinstance(data, dict): + for key, value in data.items(): + lines.append(f"- **{key}**: {orjson.dumps(value).decode()}") + elif data is not None: + lines.append(f"- {orjson.dumps(data).decode()}") + + for child in node.get("children", []): + lines.append(_render_node(child, depth + 1)) + + return "\n".join(lines) + + +def snapshot_to_markdown(snapshot: dict[str, Any]) -> str: + """Convert an LLMContextSnapshot dict to a markdown string. + + Expected shape: ``{"version": int, "nodes": [{"nodeType": str, "data": ..., "children": [...]}]}`` + The top-level nodes list contains a single root node (the page). + """ + nodes = snapshot.get("nodes", []) + if not nodes: + return "" + return _render_node(nodes[0], 0) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index 678f27964573..0cffcbd03e37 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -3,6 +3,7 @@ from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature +from sentry.utils import json @with_feature("organizations:seer-explorer") @@ -227,6 +228,47 @@ def test_get_denied_without_either_flag(self) -> None: assert response.status_code == 403 + @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") + def test_post_json_on_page_context_converted_to_markdown( + self, mock_client_class: MagicMock + ) -> None: + mock_client = MagicMock() + mock_client.start_run.return_value = 456 + mock_client_class.return_value = mock_client + + snapshot = { + "version": 1, + "nodes": [ + { + "nodeType": "dashboard", + "data": {"title": "My Dashboard", "widgetCount": 2}, + "children": [], + } + ], + } + data = {"query": "Help me", "on_page_context": json.dumps(snapshot)} + response = self.client.post(self.url, data, format="json") + + assert response.status_code == 200 + call_kwargs = mock_client.start_run.call_args[1] + context = call_kwargs["on_page_context"] + assert "# dashboard" in context + assert '- **title**: "My Dashboard"' in context + + @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") + def test_post_ascii_on_page_context_passed_through(self, mock_client_class: MagicMock) -> None: + mock_client = MagicMock() + mock_client.start_run.return_value = 456 + mock_client_class.return_value = mock_client + + ascii_screenshot = "+--------+\n| chart |\n+--------+" + data = {"query": "Help me", "on_page_context": ascii_screenshot} + response = self.client.post(self.url, data, format="json") + + assert response.status_code == 200 + call_kwargs = mock_client.start_run.call_args[1] + assert call_kwargs["on_page_context"] == ascii_screenshot + @with_feature("organizations:seer-explorer") @with_feature("organizations:gen-ai-features") diff --git a/tests/sentry/seer/explorer/test_client_utils.py b/tests/sentry/seer/explorer/test_client_utils.py index 0b6ff379d82f..da7ef5ad3b1e 100644 --- a/tests/sentry/seer/explorer/test_client_utils.py +++ b/tests/sentry/seer/explorer/test_client_utils.py @@ -2,6 +2,7 @@ from sentry.seer.explorer.client_utils import ( collect_user_org_context, has_seer_explorer_access_with_detail, + snapshot_to_markdown, ) from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestCase @@ -184,3 +185,70 @@ def test_collect_context_with_request(self) -> None: assert context is not None assert context.get("user_ip") == request.META.get("REMOTE_ADDR") + + +class SnapshotToMarkdownTest(TestCase): + def test_single_node(self) -> None: + snapshot = { + "version": 1, + "nodes": [ + { + "nodeType": "dashboard", + "data": {"title": "Backend Health", "widgetCount": 3}, + "children": [], + } + ], + } + result = snapshot_to_markdown(snapshot) + assert "# dashboard" in result + assert '- **title**: "Backend Health"' in result + assert "- **widgetCount**: 3" in result + + def test_nested_nodes(self) -> None: + snapshot = { + "version": 1, + "nodes": [ + { + "nodeType": "dashboard", + "data": {"title": "My Dashboard"}, + "children": [ + { + "nodeType": "widget", + "data": {"title": "Error Rate"}, + "children": [ + { + "nodeType": "chart", + "data": {"query": "count()"}, + "children": [], + } + ], + } + ], + } + ], + } + result = snapshot_to_markdown(snapshot) + assert "# dashboard" in result + assert "## widget" in result + assert "### chart" in result + assert '- **query**: "count()"' in result + + def test_empty_nodes(self) -> None: + assert snapshot_to_markdown({"version": 1, "nodes": []}) == "" + + def test_node_with_no_data(self) -> None: + snapshot = { + "version": 1, + "nodes": [{"nodeType": "dashboard", "data": None, "children": []}], + } + result = snapshot_to_markdown(snapshot) + assert result == "# dashboard" + + def test_node_with_non_dict_data(self) -> None: + snapshot = { + "version": 1, + "nodes": [{"nodeType": "widget", "data": "some string", "children": []}], + } + result = snapshot_to_markdown(snapshot) + assert "# widget" in result + assert '- "some string"' in result From a8a8009b4978fb947de92944f52736acd55f06bc Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 3 Apr 2026 09:23:48 -0700 Subject: [PATCH 02/35] feat(github): Handle installation_repositories webhook (#111864) Currently, we only sync the available repositories from Github on installing the integration. So over time, if new repositories are added to the github organization, or access to specific repositories is added or removed, we end up out of sync with which repositories we store in Sentry. To fix this, we are going to start handling the `installation_repositories` webhook. This is fired whenever the repositories that a github app can access change. This allows us to keep all the repos in sync. Note that when access to a repo is removed, we only ever disable the repo and never delete it. This allows us to keep the history of commits and so on so far. --- src/sentry/features/temporary.py | 1 + .../integrations/bitbucket/repository.py | 3 +- .../bitbucket_server/repository.py | 3 +- src/sentry/integrations/github/repository.py | 2 +- .../integrations/github/tasks/__init__.py | 2 + .../github/tasks/link_all_repos.py | 8 +- .../tasks/sync_repos_on_install_change.py | 136 ++++++++ src/sentry/integrations/github/webhook.py | 53 ++++ .../integrations/github/webhook_types.py | 23 +- .../github_enterprise/repository.py | 3 +- src/sentry/integrations/gitlab/repository.py | 3 +- .../integrations/perforce/repository.py | 2 +- .../integrations/services/repository/impl.py | 17 + .../services/repository/service.py | 15 + .../source_code_management/metrics.py | 1 + src/sentry/integrations/utils/metrics.py | 1 + src/sentry/integrations/vsts/repository.py | 2 +- .../middleware/integrations/parsers/github.py | 5 +- .../integrations/parsers/github_enterprise.py | 10 + .../providers/integration_repository.py | 19 +- .../test_sync_repos_on_install_change.py | 205 ++++++++++++ .../{test_webhooks.py => test_webhook.py} | 298 +++++++++++++++++- .../services/repository/test_impl.py | 162 ++++++++++ .../integrations/parsers/test_github.py | 46 +++ 24 files changed, 1002 insertions(+), 18 deletions(-) create mode 100644 src/sentry/integrations/github/tasks/sync_repos_on_install_change.py create mode 100644 tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py rename tests/sentry/integrations/github/{test_webhooks.py => test_webhook.py} (82%) create mode 100644 tests/sentry/integrations/services/repository/test_impl.py diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 00f103ab2aeb..fe3942519d5d 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -137,6 +137,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:github-repo-auto-sync", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 78aa2a539075..17610a849ad3 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -47,7 +48,7 @@ def get_webhook_secret(self, organization): return secret def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 6b3bab8c6c46..528e2bd6bd94 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index b901fc89a839..766c1e03a8a4 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -52,7 +52,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["identifier"], diff --git a/src/sentry/integrations/github/tasks/__init__.py b/src/sentry/integrations/github/tasks/__init__.py index a635eebb4b9a..cc31059167a9 100644 --- a/src/sentry/integrations/github/tasks/__init__.py +++ b/src/sentry/integrations/github/tasks/__init__.py @@ -2,10 +2,12 @@ from .codecov_account_unlink import codecov_account_unlink from .link_all_repos import link_all_repos from .pr_comment import github_comment_workflow +from .sync_repos_on_install_change import sync_repos_on_install_change __all__ = ( "codecov_account_link", "codecov_account_unlink", "github_comment_workflow", "link_all_repos", + "sync_repos_on_install_change", ) diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index ade3e8ef83a7..046c0fe46623 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from typing import Any from taskbroker_client.retry import Retry @@ -13,6 +14,7 @@ from sentry.organizations.services.organization import organization_service from sentry.plugins.providers.integration_repository import ( RepoExistsError, + RepositoryInputConfig, get_integration_repository_provider, ) from sentry.shared_integrations.exceptions import ApiError @@ -23,9 +25,9 @@ logger = logging.getLogger(__name__) -def get_repo_config(repo, integration_id): +def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryInputConfig: return { - "external_id": repo["id"], + "external_id": str(repo["id"]), "integration_id": integration_id, "identifier": repo["full_name"], } @@ -77,7 +79,7 @@ def link_all_repos( integration_repo_provider = get_integration_repository_provider(integration) - repo_configs: list[dict[str, Any]] = [] + repo_configs: list[RepositoryInputConfig] = [] missing_repos = [] for repo in repositories: try: diff --git a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py new file mode 100644 index 000000000000..c3ab3b701551 --- /dev/null +++ b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py @@ -0,0 +1,136 @@ +import logging +from typing import Literal + +from taskbroker_client.retry import Retry + +from sentry import features +from sentry.constants import ObjectStatus +from sentry.integrations.github.webhook_types import GitHubInstallationRepo +from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.services.repository.service import repository_service +from sentry.integrations.source_code_management.metrics import ( + SCMIntegrationInteractionEvent, + SCMIntegrationInteractionType, +) +from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.plugins.providers.integration_repository import ( + RepoExistsError, + RepositoryInputConfig, + get_integration_repository_provider, +) +from sentry.silo.base import SiloMode +from sentry.tasks.base import instrumented_task, retry +from sentry.taskworker.namespaces import integrations_control_tasks + +from .link_all_repos import get_repo_config + +logger = logging.getLogger(__name__) + + +@instrumented_task( + name="sentry.integrations.github.tasks.sync_repos_on_install_change", + namespace=integrations_control_tasks, + retry=Retry(times=3, delay=120), + processing_deadline_duration=120, + silo_mode=SiloMode.CONTROL, +) +@retry(exclude=(RepoExistsError, KeyError)) +def sync_repos_on_install_change( + integration_id: int, + action: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], + repository_selection: Literal["all", "selected"], +) -> None: + """ + Handle GitHub installation_repositories webhook events. + + Creates Repository records for newly accessible repos and disables + records for repos that are no longer accessible, across all orgs + linked to the integration. + """ + result = integration_service.organization_contexts(integration_id=integration_id) + integration = result.integration + org_integrations = result.organization_integrations + + if integration is None or integration.status != ObjectStatus.ACTIVE: + logger.info( + "sync_repos_on_install_change.missing_or_inactive_integration", + extra={"integration_id": integration_id}, + ) + return + + if not org_integrations: + logger.info( + "sync_repos_on_install_change.no_org_integrations", + extra={"integration_id": integration_id}, + ) + return + + provider = f"integrations:{integration.provider}" + + for oi in org_integrations: + organization_id = oi.organization_id + rpc_org = organization_service.get(id=organization_id) + + if rpc_org is None: + logger.info( + "sync_repos_on_install_change.missing_organization", + extra={"organization_id": organization_id}, + ) + continue + + if not features.has("organizations:github-repo-auto-sync", rpc_org): + continue + + with SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE, + integration_id=integration_id, + organization_id=organization_id, + provider_key=integration.provider, + ).capture(): + _sync_repos_for_org( + integration=integration, + rpc_org=rpc_org, + provider=provider, + repos_added=repos_added, + repos_removed=repos_removed, + ) + + +def _sync_repos_for_org( + *, + integration: RpcIntegration, + rpc_org: RpcOrganization, + provider: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], +) -> None: + if repos_added: + integration_repo_provider = get_integration_repository_provider(integration) + repo_configs: list[RepositoryInputConfig] = [] + for repo in repos_added: + try: + repo_configs.append(get_repo_config(repo, integration.id)) + except KeyError: + logger.exception("Failed to translate repository config") + continue + + if repo_configs: + try: + integration_repo_provider.create_repositories( + configs=repo_configs, organization=rpc_org + ) + except RepoExistsError: + pass + + if repos_removed: + external_ids = [str(repo["id"]) for repo in repos_removed] + repository_service.disable_repositories_by_external_ids( + organization_id=rpc_org.id, + integration_id=integration.id, + provider=provider, + external_ids=external_ids, + ) diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index b5b86fea0c0a..28e87abd1ef5 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -30,6 +30,7 @@ from sentry.integrations.github.webhook_types import ( GITHUB_WEBHOOK_TYPE_HEADER_KEY, GithubWebhookType, + InstallationRepositoriesEvent, ) from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import ( @@ -418,6 +419,57 @@ def _handle_organization_deletion( ) +class InstallationRepositoriesEventWebhook(GitHubWebhook): + """ + Handles installation_repositories events when repos are added to or + removed from the GitHub App installation. Runs in control silo. + + https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories + """ + + EVENT_TYPE = IntegrationWebhookEventType.INSTALLATION_REPOSITORIES + + def __call__( # type: ignore[override] + self, event: InstallationRepositoriesEvent, host: str | None = None, **kwargs: Any + ) -> None: + external_id = get_github_external_id(event=event, host=host) + if external_id is None: + return + + result = integration_service.organization_contexts( + provider=self.provider, + external_id=external_id, + ) + integration = result.integration + + if integration is None: + logger.warning( + "github.installation_repositories.missing_integration", + extra={"external_id": str(external_id)}, + ) + return + + action = event["action"] + repos_added = event["repositories_added"] + repos_removed = event["repositories_removed"] + repository_selection = event["repository_selection"] + + if not repos_added and not repos_removed: + return + + from .tasks.sync_repos_on_install_change import sync_repos_on_install_change + + sync_repos_on_install_change.apply_async( + kwargs={ + "integration_id": integration.id, + "action": action, + "repos_added": repos_added, + "repos_removed": repos_removed, + "repository_selection": repository_selection, + } + ) + + class PushEventWebhook(GitHubWebhook): """https://developer.github.com/v3/activity/events/types/#pushevent""" @@ -958,6 +1010,7 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint): _handlers: dict[GithubWebhookType, type[GitHubWebhook]] = { GithubWebhookType.CHECK_RUN: CheckRunEventWebhook, GithubWebhookType.INSTALLATION: InstallationEventWebhook, + GithubWebhookType.INSTALLATION_REPOSITORIES: InstallationRepositoriesEventWebhook, GithubWebhookType.ISSUE: IssuesEventWebhook, GithubWebhookType.ISSUE_COMMENT: IssueCommentEventWebhook, GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook, diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index 0ad1061471a0..eaad179b8ae1 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Any, Literal, TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -22,7 +23,25 @@ class GithubWebhookType(StrEnum): # Event type strings (X-GitHub-Event header values) that the cell webhook endpoint processes. -# INSTALLATION is handled in control only. +# INSTALLATION and INSTALLATION_REPOSITORIES are handled in control only. +_CONTROL_ONLY_EVENTS = frozenset( + {GithubWebhookType.INSTALLATION, GithubWebhookType.INSTALLATION_REPOSITORIES} +) CELL_PROCESSED_GITHUB_EVENTS = frozenset( - t.value for t in GithubWebhookType if t != GithubWebhookType.INSTALLATION + t.value for t in GithubWebhookType if t not in _CONTROL_ONLY_EVENTS ) + + +class GitHubInstallationRepo(TypedDict): + id: int + full_name: str + private: bool + + +class InstallationRepositoriesEvent(TypedDict): + action: Literal["added", "removed"] + installation: dict[str, Any] + repositories_added: list[GitHubInstallationRepo] + repositories_removed: list[GitHubInstallationRepo] + repository_selection: Literal["all", "selected"] + sender: dict[str, Any] diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 5f256206ffd4..2835befdf391 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.github.repository import GitHubRepositoryProvider @@ -29,7 +30,7 @@ def _validate_repo(self, client, installation, repo): return repo_data def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: integration = integration_service.get_integration( integration_id=data["integration_id"], provider=self.repo_provider diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index 1b889c641c5c..d2285d73b195 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 0adea7741301..52d84dd91c13 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -102,7 +102,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Build repository configuration for database storage. diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index 39238a71778c..d1cf84cdcb44 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -134,6 +134,23 @@ def disable_repositories_for_integration( provider=provider, ).update(status=ObjectStatus.DISABLED) + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + with transaction.atomic(router.db_for_write(Repository)): + Repository.objects.filter( + organization_id=organization_id, + integration_id=integration_id, + provider=provider, + external_id__in=external_ids, + status=ObjectStatus.ACTIVE, + ).update(status=ObjectStatus.DISABLED) + def disassociate_organization_integration( self, *, diff --git a/src/sentry/integrations/services/repository/service.py b/src/sentry/integrations/services/repository/service.py index a10d8c42852a..51cb81c98ba8 100644 --- a/src/sentry/integrations/services/repository/service.py +++ b/src/sentry/integrations/services/repository/service.py @@ -85,6 +85,21 @@ def disable_repositories_for_integration( Code owners and code mappings will not be changed. """ + @cell_rpc_method(resolve=ByOrganizationId()) + @abstractmethod + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + """ + Disables specific repositories by external_id for a given integration. + Only active repositories are affected. Code mappings and commits are preserved. + """ + @cell_rpc_method(resolve=ByOrganizationId()) @abstractmethod def disassociate_organization_integration( diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index 6cc035d5bcab..a6612f568092 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -41,6 +41,7 @@ class SCMIntegrationInteractionType(StrEnum): # Tasks LINK_ALL_REPOS = "link_all_repos" + SYNC_REPOS_ON_INSTALL_CHANGE = "sync_repos_on_install_change" # GitHub only DERIVE_CODEMAPPINGS = "derive_codemappings" diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index 6d0f8ea33ea2..a341f8c31833 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -448,6 +448,7 @@ class IntegrationWebhookEventType(StrEnum): # This represents a webhook event for an inbound sync operation, such as syncing external resources or data into Sentry. INBOUND_SYNC = "inbound_sync" INSTALLATION = "installation" + INSTALLATION_REPOSITORIES = "installation_repositories" ISSUE_COMMENT = "issue_comment" MERGE_REQUEST = "pull_request" MERGE_REQUEST_REVIEW = "pull_request_review" diff --git a/src/sentry/integrations/vsts/repository.py b/src/sentry/integrations/vsts/repository.py index f9a9b74007ac..ac0157719601 100644 --- a/src/sentry/integrations/vsts/repository.py +++ b/src/sentry/integrations/vsts/repository.py @@ -47,7 +47,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["name"], diff --git a/src/sentry/middleware/integrations/parsers/github.py b/src/sentry/middleware/integrations/parsers/github.py index c6ec3f3c5bb1..f7c591953467 100644 --- a/src/sentry/middleware/integrations/parsers/github.py +++ b/src/sentry/middleware/integrations/parsers/github.py @@ -77,7 +77,10 @@ def get_mailbox_identifier( def should_route_to_control_silo( self, parsed_event: Mapping[str, Any], request: HttpRequest ) -> bool: - return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION + return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) in ( + GithubWebhookType.INSTALLATION, + GithubWebhookType.INSTALLATION_REPOSITORIES, + ) @control_silo_function def get_integration_from_request(self) -> Integration | None: diff --git a/src/sentry/middleware/integrations/parsers/github_enterprise.py b/src/sentry/middleware/integrations/parsers/github_enterprise.py index 3f7cbdce60d0..02edd104dfdb 100644 --- a/src/sentry/middleware/integrations/parsers/github_enterprise.py +++ b/src/sentry/middleware/integrations/parsers/github_enterprise.py @@ -4,8 +4,11 @@ from collections.abc import Mapping from typing import Any +from django.http import HttpRequest + from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier from sentry.integrations.github.webhook import get_github_external_id +from sentry.integrations.github.webhook_types import GITHUB_WEBHOOK_TYPE_HEADER, GithubWebhookType from sentry.integrations.github_enterprise.webhook import GitHubEnterpriseWebhookEndpoint, get_host from sentry.integrations.types import IntegrationProviderSlug from sentry.middleware.integrations.parsers.github import GithubRequestParser @@ -18,6 +21,13 @@ class GithubEnterpriseRequestParser(GithubRequestParser): webhook_identifier = WebhookProviderIdentifier.GITHUB_ENTERPRISE webhook_endpoint = GitHubEnterpriseWebhookEndpoint + def should_route_to_control_silo( + self, parsed_event: Mapping[str, Any], request: HttpRequest + ) -> bool: + # GHE only routes installation events to control silo. + # installation_repositories is not yet supported for GHE. + return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION + def _get_external_id(self, event: Mapping[str, Any]) -> str | None: host = get_host(request=self.request) if not host: diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 9be762ce7668..e238eccd3124 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping from datetime import timezone -from typing import Any, ClassVar, TypedDict +from typing import Any, ClassVar, NotRequired, TypedDict from dateutil.parser import parse as parse_date from rest_framework import status @@ -27,6 +28,16 @@ from sentry.utils import metrics +class RepositoryInputConfig(TypedDict): + """Input config passed to create_repositories / build_repository_config. + Providers may include additional keys beyond these.""" + + external_id: str + integration_id: int + identifier: str + installation: NotRequired[str] + + class RepositoryConfig(TypedDict): name: str external_id: str @@ -107,7 +118,7 @@ def get_installation( def create_repository( self, - repo_config: dict[str, Any], + repo_config: Mapping[str, Any], organization: RpcOrganization, ): result = self.build_repository_config(organization=organization, data=repo_config) @@ -227,7 +238,7 @@ def _update_repositories( def create_repositories( self, - configs: list[dict[str, Any]], + configs: list[RepositoryInputConfig], organization: RpcOrganization, ): external_id_to_repo_config: dict[str, RepositoryConfig] = {} @@ -354,7 +365,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Builds final dict containing all necessary data to create the repository diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py new file mode 100644 index 000000000000..9f63922c72d2 --- /dev/null +++ b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py @@ -0,0 +1,205 @@ +from unittest.mock import MagicMock, patch + +from sentry.constants import ObjectStatus +from sentry.integrations.github.integration import GitHubIntegrationProvider +from sentry.integrations.github.tasks.sync_repos_on_install_change import ( + sync_repos_on_install_change, +) +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode +from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test + +FEATURE_FLAG = "organizations:github-repo-auto-sync" + + +@control_silo_test +@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") +class SyncReposOnInstallChangeTestCase(IntegrationTestCase): + provider = GitHubIntegrationProvider + base_url = "https://api.github.com" + key = "github" + + def _make_repos_added(self): + return [ + {"id": 1, "full_name": "getsentry/sentry", "private": False}, + {"id": 2, "full_name": "getsentry/snuba", "private": False}, + ] + + def _make_repos_removed(self): + return [ + {"id": 3, "full_name": "getsentry/old-repo", "private": False}, + ] + + def test_repos_added(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[0].integration_id == self.integration.id + assert repos[1].name == "getsentry/snuba" + + def test_repos_removed(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_mixed_add_and_remove(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + old_repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + old_repo.refresh_from_db() + assert old_repo.status == ObjectStatus.DISABLED + + active_repos = Repository.objects.filter( + organization_id=self.organization.id, + status=ObjectStatus.ACTIVE, + ).order_by("name") + assert len(active_repos) == 2 + assert active_repos[0].name == "getsentry/sentry" + assert active_repos[1].name == "getsentry/snuba" + + def test_multi_org(self, _: MagicMock) -> None: + other_org = self.create_organization(owner=self.user) + self.create_organization_integration( + organization_id=other_org.id, + integration=self.integration, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos_org1 = Repository.objects.filter(organization_id=self.organization.id) + repos_org2 = Repository.objects.filter(organization_id=other_org.id) + + assert len(repos_org1) == 2 + assert len(repos_org2) == 2 + + def test_missing_integration(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=0, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_inactive_integration(self, _: MagicMock) -> None: + self.integration.update(status=ObjectStatus.DISABLED) + + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_feature_flag_off(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_empty_repos_is_noop(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=[], + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_does_not_disable_already_disabled_repos(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED diff --git a/tests/sentry/integrations/github/test_webhooks.py b/tests/sentry/integrations/github/test_webhook.py similarity index 82% rename from tests/sentry/integrations/github/test_webhooks.py rename to tests/sentry/integrations/github/test_webhook.py index bd3b637b473a..843b76e2e4dd 100644 --- a/tests/sentry/integrations/github/test_webhooks.py +++ b/tests/sentry/integrations/github/test_webhook.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -19,7 +20,11 @@ ) from sentry import options from sentry.constants import ObjectStatus -from sentry.integrations.github.webhook import GitHubIntegrationsWebhookEndpoint +from sentry.integrations.github.webhook import ( + GitHubIntegrationsWebhookEndpoint, + InstallationRepositoriesEventWebhook, +) +from sentry.integrations.github.webhook_types import InstallationRepositoriesEvent from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.integration import integration_service @@ -363,6 +368,297 @@ def test_installation_deleted_skips_codecov_unlink_when_app_ids_dont_match( mock_codecov_unlink.assert_not_called() +@control_silo_test +class InstallationRepositoriesEventWebhookTest(APITestCase): + def setUp(self) -> None: + self.url = "/extensions/github/webhook/" + self.secret = "b3002c3e321d4b7880360d397db2ccfd" + options.set("github-app.webhook-secret", self.secret) + + def _make_event(self, action="added", repos_added=None, repos_removed=None): + return json.dumps( + { + "action": action, + "installation": {"id": 2}, + "repositories_added": repos_added or [], + "repositories_removed": repos_removed or [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + def _compute_signatures(self, body: str) -> tuple[str, str]: + sha1 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha1", body.encode(), self.secret + ) + sha256 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha256", body.encode(), self.secret + ) + return f"sha1={sha1}", f"sha256={sha256}" + + @patch("sentry.integrations.github.webhook.InstallationRepositoriesEventWebhook.__call__") + def test_webhook_dispatches_to_handler(self, mock_call: MagicMock) -> None: + """Verify the endpoint routes installation_repositories events to the correct handler.""" + body = self._make_event( + repos_added=[{"id": 1, "full_name": "getsentry/sentry", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + assert mock_call.called + + def test_end_to_end_repos_added(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository rows created.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + body = self._make_event( + repos_added=[ + {"id": 10, "full_name": "getsentry/sentry", "private": False}, + {"id": 20, "full_name": "getsentry/snuba", "private": False}, + ], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[1].name == "getsentry/snuba" + + def test_end_to_end_repos_removed(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository disabled.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="30", + provider="integrations:github", + integration_id=integration.id, + status=ObjectStatus.ACTIVE, + ) + + body = self._make_event( + action="removed", + repos_removed=[{"id": 30, "full_name": "getsentry/old-repo", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_added(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_added dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [ + {"id": 10, "full_name": "getsentry/sentry", "private": False} + ], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["integration_id"] == integration.id + assert kwargs["action"] == "added" + assert len(kwargs["repos_added"]) == 1 + assert kwargs["repos_added"][0]["id"] == 10 + assert kwargs["repos_removed"] == [] + assert kwargs["repository_selection"] == "selected" + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_removed(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_removed dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "removed", + "repositories_added": [], + "repositories_removed": [ + {"id": 20, "full_name": "getsentry/old-repo", "private": False} + ], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["action"] == "removed" + assert len(kwargs["repos_removed"]) == 1 + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_no_repos(self, mock_apply_async: MagicMock) -> None: + """No repos added or removed — task should not be dispatched.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_malformed_event(self, mock_apply_async: MagicMock) -> None: + """Malformed event missing required keys — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + malformed_event = cast( + InstallationRepositoriesEvent, + {"repositories_added": [{"id": 1}], "repositories_removed": []}, + ) + handler(event=malformed_event) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_integration_not_found(self, mock_apply_async: MagicMock) -> None: + """Integration doesn't exist in Sentry — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 99999}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_propagates_host_for_ghe(self, mock_apply_async: MagicMock) -> None: + """GitHub Enterprise uses host prefix for external_id.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="github.mycompany.com:2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + }, + host="github.mycompany.com", + ) + + mock_apply_async.assert_called_once() + + class PushEventWebhookTest(APITestCase): def setUp(self) -> None: self.url = "/extensions/github/webhook/" diff --git a/tests/sentry/integrations/services/repository/test_impl.py b/tests/sentry/integrations/services/repository/test_impl.py new file mode 100644 index 000000000000..a92df36cc470 --- /dev/null +++ b/tests/sentry/integrations/services/repository/test_impl.py @@ -0,0 +1,162 @@ +from sentry.constants import ObjectStatus +from sentry.integrations.services.repository.service import repository_service +from sentry.models.repository import Repository +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import cell_silo_test + + +@cell_silo_test +class DisableRepositoriesByExternalIdsTest(TestCase): + def setUp(self) -> None: + self.integration = self.create_integration( + organization=self.organization, + external_id="1", + provider="github", + ) + self.provider = "integrations:github" + + def test_disables_matching_active_repos(self) -> None: + repo1 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo2 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100", "200"], + ) + + repo1.refresh_from_db() + repo2.refresh_from_db() + assert repo1.status == ObjectStatus.DISABLED + assert repo2.status == ObjectStatus.DISABLED + + def test_does_not_disable_already_disabled_repos(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_does_not_affect_repos_from_other_integrations(self) -> None: + other_integration = self.create_integration( + organization=self.organization, + external_id="2", + provider="github", + ) + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=other_integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_does_not_affect_repos_from_other_orgs(self) -> None: + other_org = self.create_organization() + repo = Repository.objects.create( + organization_id=other_org.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_only_disables_specified_external_ids(self) -> None: + repo_to_disable = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo_to_keep = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo_to_disable.refresh_from_db() + repo_to_keep.refresh_from_db() + assert repo_to_disable.status == ObjectStatus.DISABLED + assert repo_to_keep.status == ObjectStatus.ACTIVE + + def test_empty_external_ids_is_noop(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=[], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py index 8abbfcd5de55..eb2f7f5c470e 100644 --- a/tests/sentry/middleware/integrations/parsers/test_github.py +++ b/tests/sentry/middleware/integrations/parsers/test_github.py @@ -139,6 +139,52 @@ def test_get_integration_from_request(self) -> None: result = parser.get_integration_from_request() assert result == integration + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_repositories_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={ + "installation": {"id": "1"}, + "repositories_added": [], + "repositories_removed": [], + }, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION_REPOSITORIES.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_push_does_not_route_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.PUSH.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert not parser.should_route_to_control_silo(parsed_event={}, request=request) + @override_settings(SILO_MODE=SiloMode.CONTROL) @override_cells(cell_config) def test_webhook_outbox_creation(self) -> None: From 8348ba9d4134b084da771f2f9076a0dc361d60ba Mon Sep 17 00:00:00 2001 From: Hubert Deng Date: Fri, 3 Apr 2026 09:25:37 -0700 Subject: [PATCH 03/35] fix(billing): fix flaky paymentForm test by awaiting button enabled state (#112188) ## Summary - The `paymentForm.spec.tsx` test "renders an error when intent creation fails" was flaky because it asserted `toBeEnabled()` on the "Pay Now" button synchronously - The mock Stripe `PaymentElement` fires `onChange({complete: true})` inside a `setTimeout(..., 0)`, so the button's enabled state update races with the assertion - Wrapped the assertion in `waitFor()` to reliably wait for the async state update ## Root Cause In `tests/js/setup.ts`, the global Stripe mock for `PaymentElement` uses `setTimeout(() => { onChange({complete: true}) }, 0)` to simulate form completion. In `InnerIntentForm`, the submit button starts disabled (`submitDisabled = true`) and only becomes enabled when `onChange` fires with `complete: true`. The synchronous `toBeEnabled()` assertion could run before the setTimeout callback executed, causing intermittent failures. ## Test plan - [x] Pre-commit passes - [x] CI passes --- static/gsApp/views/invoiceDetails/paymentForm.spec.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx b/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx index 20bb0dff19b9..aea0fb33622c 100644 --- a/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx +++ b/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx @@ -94,8 +94,11 @@ describe('InvoiceDetails > Payment Form', () => { expect(await screen.findByText(/Something bad happened./)).toBeInTheDocument(); expect(mockget).toHaveBeenCalled(); - // Submit the form anyways - expect(screen.getByRole('button', {name: 'Pay Now'})).toBeEnabled(); + // Submit the form anyways - wait for the button to become enabled + // (the mock Stripe PaymentElement fires onChange asynchronously via setTimeout) + await waitFor(() => + expect(screen.getByRole('button', {name: 'Pay Now'})).toBeEnabled() + ); await userEvent.click(screen.getByRole('button', {name: 'Pay Now'})); // Should show an error as our intent never loaded. @@ -122,7 +125,7 @@ describe('InvoiceDetails > Payment Form', () => { await waitFor(() => expect(mockget).toHaveBeenCalled()); expect(mockget).toHaveBeenCalled(); - expect(screen.getByText('Pay Bill')).toBeInTheDocument(); + expect(await screen.findByText('Pay Bill')).toBeInTheDocument(); const button = await screen.findByRole('button', {name: 'Pay Now'}); await userEvent.click(button); From 773a39cd802fbebef41a8d1962f6b7150e2351e0 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Fri, 3 Apr 2026 09:35:31 -0700 Subject: [PATCH 04/35] feat(occurrences on eap): Implement tagstore EAP query for release tags (#111952) Implements double reads of occurrences from EAP for `get_release_tags` in `src/sentry/tagstore/snuba/backend.py`. --- src/sentry/tagstore/snuba/backend.py | 141 +++++++++++++++++- tests/snuba/tagstore/test_tagstore_backend.py | 48 +++++- 2 files changed, 184 insertions(+), 5 deletions(-) diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 1df6578b8626..64e528122392 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -40,8 +40,10 @@ from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment from sentry.models.releases.release_project import ReleaseProject from sentry.replays.query import query_replays_dataset_tagkey_values +from sentry.search.eap.columns import datetime_processor from sentry.search.eap.occurrences.common_queries import count_occurrences from sentry.search.eap.occurrences.definitions import OCCURRENCE_DEFINITIONS +from sentry.search.eap.occurrences.query_utils import build_escaped_term_filter from sentry.search.eap.occurrences.rollout_utils import EAPOccurrencesComparator from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.types import SearchResolverConfig @@ -145,6 +147,23 @@ def _reasonable_user_counts_match(control: dict[int, int], experimental: dict[in return all(experimental[group_id] <= control[group_id] for group_id in experimental) +def _reasonable_release_tags_match(control: set[TagValue], experimental: set[TagValue]) -> bool: + exp_by_value: dict[str | None, TagValue] = {tv.value: tv for tv in experimental} + ctrl_by_value: dict[str | None, TagValue] = {} + for tv in control: + existing = ctrl_by_value.get(tv.value) + if existing is None or (tv.times_seen or 0) > (existing.times_seen or 0): + ctrl_by_value[tv.value] = tv + + if not set(exp_by_value.keys()).issubset(set(ctrl_by_value.keys())): + return False + + return all( + (exp_tv.times_seen or 0) <= (ctrl_by_value[value].times_seen or 0) + for value, exp_tv in exp_by_value.items() + ) + + class _OptimizeKwargs(TypedDict, total=False): turbo: bool sample: int @@ -1136,7 +1155,7 @@ def get_release_tags(self, organization_id, project_ids, environment_id, version ["max", SEEN_COLUMN, "last_seen"], ] start = self.get_min_start_date(organization_id, project_ids, environment_id, versions) - result = snuba.query( + snuba_result_raw = snuba.query( dataset=Dataset.Events, start=start, groupby=["project_id", col], @@ -1144,16 +1163,130 @@ def get_release_tags(self, organization_id, project_ids, environment_id, version filter_keys=filters, aggregations=aggregations, orderby="-times_seen", - referrer="tagstore.get_release_tags", + referrer=Referrer.TAGSTORE_GET_RELEASE_TAGS.value, tenant_ids={"organization_id": organization_id}, ) values = [] - for project_data in result.values(): + for project_data in snuba_result_raw.values(): for value, data in project_data.items(): values.append(TagValue(key=tag, value=value, **fix_tag_value_data(data))) - return set(values) + snuba_result = set(values) + result = snuba_result + + callsite = "SnubaTagStorage::get_release_tags" + if EAPOccurrencesComparator.should_check_experiment(callsite): + eap_result = self._eap_get_release_tags( + organization_id, project_ids, environment_id, versions, start + ) + result = EAPOccurrencesComparator.check_and_choose( + control_data=snuba_result, + experimental_data=eap_result, + callsite=callsite, + is_experimental_data_a_null_result=len(eap_result) == 0, + reasonable_match_comparator=_reasonable_release_tags_match, + debug_context={ + "organization_id": organization_id, + "project_ids": list(project_ids), + "environment_id": environment_id, + "versions": list(versions), + }, + ) + + return result + + def _eap_get_release_tags( + self, + organization_id: int, + project_ids: Sequence[int], + environment_id: int | None, + versions: Sequence[str], + start: datetime | None, + ) -> set[TagValue]: + try: + organization = Organization.objects.get_from_cache(id=organization_id) + except Organization.DoesNotExist: + return set() + + projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) + if not projects: + return set() + + environments = list(Environment.objects.filter(id=environment_id)) if environment_id else [] + + now = datetime.now(tz=timezone.utc) + resolved_start = start if start is not None else now - timedelta(days=90) + + query_string = build_escaped_term_filter("release", [str(v) for v in versions]) + + snuba_params = SnubaParams( + start=resolved_start, + end=now, + organization=organization, + projects=projects, + environments=environments, + ) + + try: + result = Occurrences.run_table_query( + params=snuba_params, + query_string=query_string, + selected_columns=[ + "project_id", + "release", + "count()", + "min(timestamp)", + "last_seen()", + ], + orderby=["-count()"], + offset=0, + limit=len(versions) * len(project_ids), + referrer=Referrer.TAGSTORE_GET_RELEASE_TAGS.value, + config=SearchResolverConfig(), + occurrence_category=OccurrenceCategory.ERROR, + ) + + tag = "sentry:release" + tag_values = [] + for row in result.get("data", []): + release_val = row.get("release") + if release_val is None: + continue + first_seen_raw = row.get("min(timestamp)") + last_seen_raw = row.get("last_seen()") + tag_values.append( + TagValue( + key=tag, + value=release_val, + times_seen=int(row.get("count()", 0)), + first_seen=( + parse_datetime(datetime_processor(first_seen_raw)).replace( + tzinfo=timezone.utc + ) + if first_seen_raw is not None + else None + ), + last_seen=( + parse_datetime(datetime_processor(last_seen_raw)).replace( + tzinfo=timezone.utc + ) + if last_seen_raw is not None + else None + ), + ) + ) + return set(tag_values) + except Exception: + logger.exception( + "EAP get_release_tags query failed", + extra={ + "organization_id": organization_id, + "project_ids": list(project_ids), + "versions": list(versions), + }, + ) + return set() def get_min_start_date( self, organization_id, project_ids, environment_id, versions diff --git a/tests/snuba/tagstore/test_tagstore_backend.py b/tests/snuba/tagstore/test_tagstore_backend.py index f50bcb3a0c96..e7262a5b80fa 100644 --- a/tests/snuba/tagstore/test_tagstore_backend.py +++ b/tests/snuba/tagstore/test_tagstore_backend.py @@ -1568,7 +1568,7 @@ def test_semver_package(self) -> None: self.run_test("4", ["456"], env_2) -class TestEAPGetGroupsUserCounts(TestCase, SnubaTestCase, OccurrenceTestCase): +class TestEAPTagStorageQueries(TestCase, SnubaTestCase, OccurrenceTestCase): FROZEN_TIME = before_now(hours=24).replace(hour=6, minute=0, second=0) def setUp(self) -> None: @@ -1865,3 +1865,49 @@ def test_eap_issue_platform_user_counts_with_time_range_filter(self) -> None: ) assert eap_result == {group_a.id: 1} + + @freeze_time(FROZEN_TIME) + def test_eap_and_snuba_release_tags_match(self) -> None: + ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp() + + # Store events with release tags for two different releases + for _ in range(2): + self.store_events_to_snuba_and_eap( + "group-rel", + count=1, + timestamp=ts, + extra_event_data={ + "tags": {"sentry:release": "1.0"}, + "release": "1.0", + }, + ) + self.store_events_to_snuba_and_eap( + "group-rel", + count=1, + timestamp=ts, + extra_event_data={ + "tags": {"sentry:release": "2.0"}, + "release": "2.0", + }, + ) + + start = self.FROZEN_TIME - timedelta(hours=1) + + snuba_result = self.ts.get_release_tags( + self.organization.id, [self.project.id], None, ["1.0", "2.0"] + ) + eap_result = self.ts._eap_get_release_tags( + self.organization.id, [self.project.id], None, ["1.0", "2.0"], start + ) + + snuba_by_value = {tv.value: tv for tv in snuba_result} + eap_by_value = {tv.value: tv for tv in eap_result} + + assert set(snuba_by_value.keys()) == {"1.0", "2.0"} + assert set(eap_by_value.keys()) == set(snuba_by_value.keys()) + assert snuba_by_value["1.0"].times_seen == eap_by_value["1.0"].times_seen == 2 + assert snuba_by_value["2.0"].times_seen == eap_by_value["2.0"].times_seen == 1 + assert snuba_by_value["1.0"].first_seen == eap_by_value["1.0"].first_seen + assert snuba_by_value["1.0"].last_seen == eap_by_value["1.0"].last_seen + assert snuba_by_value["2.0"].first_seen == eap_by_value["2.0"].first_seen + assert snuba_by_value["2.0"].last_seen == eap_by_value["2.0"].last_seen From 5c597cb012c3df21b38f03e01c778f5a99984dc8 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 3 Apr 2026 09:36:23 -0700 Subject: [PATCH 05/35] feat(seer): Improve the loading state of the Seer SCM overview area (#112054) An iteration on the loading state. Before it would show a sort-of incremental count and still be spinning along. It looked like it was stuck or something because it wasn't ticking up consistently. Now we hold back on showing the count and buttons until we're fully ready to go. For these >800 repos it takes ~20seconds to load up for me. https://github.com/user-attachments/assets/c865cb0c-014b-4239-a50e-e685c28e36c3 --- .github/CODEOWNERS | 1 + .../app/components/loading/useCycleText.tsx | 31 ++++++++++++++ .../scmIntegrationTreeRow.tsx | 34 ++++++---------- .../seer/overview/scmOverviewSection.tsx | 40 ++++++++++++++----- 4 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 static/app/components/loading/useCycleText.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 891c0ad90ce9..408881367d3a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -435,6 +435,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get ## Frontend /static/app/components/analyticsArea.spec.tsx @getsentry/app-frontend /static/app/components/analyticsArea.tsx @getsentry/app-frontend +/static/app/components/loading/ @getsentry/app-frontend /static/app/components/events/interfaces/ @getsentry/app-frontend /static/app/components/forms/ @getsentry/app-frontend /static/app/locale.tsx @getsentry/app-frontend diff --git a/static/app/components/loading/useCycleText.tsx b/static/app/components/loading/useCycleText.tsx new file mode 100644 index 000000000000..da12245f087f --- /dev/null +++ b/static/app/components/loading/useCycleText.tsx @@ -0,0 +1,31 @@ +import {useEffect, useState} from 'react'; + +import {useTimeout} from 'sentry/utils/useTimeout'; + +interface Props { + delayMs: number; + messages: string[]; + disabled?: boolean; +} + +export function useCycleText({messages, delayMs, disabled = false}: Props) { + const [messageIndex, setMessageIndex] = useState(0); + + const {start, cancel} = useTimeout({ + timeMs: delayMs, + onTimeout: () => { + setMessageIndex(prev => Math.min(prev + 1, messages.length - 1)); + start(); + }, + }); + + useEffect(() => { + if (disabled) { + cancel(); + } else { + start(); + } + }, [start, cancel, disabled]); + + return messages[messageIndex]; +} diff --git a/static/app/components/repositories/scmIntegrationTree/scmIntegrationTreeRow.tsx b/static/app/components/repositories/scmIntegrationTree/scmIntegrationTreeRow.tsx index bfbc3fca0376..35c291c05d7e 100644 --- a/static/app/components/repositories/scmIntegrationTree/scmIntegrationTreeRow.tsx +++ b/static/app/components/repositories/scmIntegrationTree/scmIntegrationTreeRow.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useState, type CSSProperties, type ReactNode} from 'react'; +import {Fragment, useState, type CSSProperties, type ReactNode} from 'react'; import styled from '@emotion/styled'; import {Badge, Tag} from '@sentry/scraps/badge'; @@ -10,6 +10,7 @@ import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {hasEveryAccess} from 'sentry/components/acl/access'; +import {useCycleText} from 'sentry/components/loading/useCycleText'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {RepoProviderIcon} from 'sentry/components/repositories/repoProviderIcon'; import {ProviderConfigLink} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink'; @@ -24,7 +25,6 @@ import type { } from 'sentry/types/integrations'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {useTimeout} from 'sentry/utils/useTimeout'; import {AddIntegrationButton} from 'sentry/views/settings/organizationIntegrations/addIntegrationButton'; // --------------------------------------------------------------------------- @@ -458,30 +458,20 @@ function RemoveButton({ ); } +const messages = [ + t('Loading repos...'), + t('Loading a few repos...'), + t('Loading a lot more repos...'), + t('This is getting interesting...'), + t('Almost done...'), + t('Just kidding, still loading...'), +]; function LoadingReposMessage() { - const [messageIndex, setMessageIndex] = useState(0); - const {start} = useTimeout({ - timeMs: 5000, - onTimeout: () => { - setMessageIndex(prev => prev + 1); - start(); - }, - }); - useEffect(start, [start]); - - const messages = [ - t('Loading repos...'), - t('Loading a few repos...'), - t('Loading a lot more repos...'), - t('This is getting interesting...'), - t('Almost done...'), - t('Just kidding, still loading...'), - ]; - + const message = useCycleText({messages, delayMs: 5000}); return ( - {messages[Math.min(messageIndex, messages.length - 1)]} + {message} diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx index b898ebe03ca4..d0a9db12595a 100644 --- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx @@ -21,7 +21,9 @@ import { isSeerSupportedProvider, useSeerSupportedProviderIds, } from 'sentry/components/events/autofix/utils'; +import {useCycleText} from 'sentry/components/loading/useCycleText'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {Placeholder} from 'sentry/components/placeholder'; import {RepoProviderIcon} from 'sentry/components/repositories/repoProviderIcon'; import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink'; import {useScmIntegrationTreeData} from 'sentry/components/repositories/scmIntegrationTree/useScmIntegrationTreeData'; @@ -34,7 +36,6 @@ import type { import {defined} from 'sentry/utils'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {fetchMutation} from 'sentry/utils/queryClient'; - interface SCMOverviewSectionData { connectedRepos: IntegrationRepository[]; isError: boolean; @@ -119,6 +120,7 @@ export function SCMOverviewSection(props: Props) { const { isError, isPending, + isReposPending, organizationSlug, refetchIntegrations, seerRepos, @@ -140,12 +142,10 @@ export function SCMOverviewSection(props: Props) { } > {isPending ? ( - - - - {t('Loading source code providers and repositories...')} - - + + + + ) : isError ? ( {t('Error loading repositories')} + ) : isReposPending ? ( + ) : supportedScmIntegrations.length === 0 ? ( ) : seerRepos.length === 0 ? ( @@ -245,6 +247,13 @@ function NoRepos({supportedScmIntegrations}: Props) { ); } +const messages = [ + t('Loading source code providers and repositories...'), + t('Loading a lot of repositories...'), + t('This is getting interesting...'), + t('Almost done...'), + t('Just kidding, still loading...'), +]; function AddedRepos({ canWrite, connectedRepos, @@ -254,14 +263,23 @@ function AddedRepos({ seerRepos, unconnectedRepos, }: Props) { + const message = useCycleText({messages, delayMs: 5000, disabled: !isReposPending}); return ( - {isReposPending ? : null} - {seerRepos.length === 1 - ? t('1 Repository Added') - : t('%s of %s Repositories Added', connectedRepos.length, seerRepos.length)} + {isReposPending ? ( + + + + {message} + + + ) : seerRepos.length === 1 ? ( + t('1 Repository Added') + ) : ( + t('%s of %s Repositories Added', connectedRepos.length, seerRepos.length) + )} From 34d7465f6b93b89c133e0613368124b5ff394435 Mon Sep 17 00:00:00 2001 From: Christinarlong <60594860+Christinarlong@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:51:14 -0700 Subject: [PATCH 06/35] feat(notifications): Hook into the platform in the slack send_alert step for metric laerts (#112189) --- .../handlers/slack_metric_alert_handler.py | 139 +++++++++++++++-- .../test_slack_metric_alert_handler.py | 140 ++++++++++++------ 2 files changed, 224 insertions(+), 55 deletions(-) diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py index 04cd547b9d76..704f7cc8246d 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py @@ -1,12 +1,19 @@ import logging -from sentry.incidents.models.incident import TriggerStatus +import sentry_sdk + +from sentry import features +from sentry.incidents.charts import build_metric_alert_chart +from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializerResponse +from sentry.incidents.endpoints.serializers.incident import DetailedIncidentSerializerResponse +from sentry.incidents.models.incident import IncidentStatus, TriggerStatus from sentry.incidents.typings.metric_detector import ( AlertContext, MetricIssueContext, NotificationContext, OpenPeriodContext, ) +from sentry.integrations.metric_alerts import incident_attachment_info from sentry.integrations.slack.utils.notifications import send_incident_alert_notification from sentry.models.groupopenperiod import GroupOpenPeriod from sentry.models.organization import Organization @@ -18,11 +25,106 @@ ) from sentry.notifications.notification_action.registry import metric_alert_handler_registry from sentry.notifications.notification_action.types import BaseMetricAlertHandler +from sentry.notifications.platform.service import NotificationService +from sentry.notifications.platform.target import IntegrationNotificationTarget +from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData +from sentry.notifications.platform.threading import ThreadingOptions, ThreadKey +from sentry.notifications.platform.types import ( + NotificationProviderKey, + NotificationSource, + NotificationTargetResourceType, +) +from sentry.workflow_engine.endpoints.serializers.detector_serializer import ( + DetectorSerializerResponse, +) from sentry.workflow_engine.models import Action, Detector logger = logging.getLogger(__name__) +def _send_via_notification_platform( + notification_context: NotificationContext, + alert_context: AlertContext, + metric_issue_context: MetricIssueContext, + open_period_context: OpenPeriodContext, + notification_uuid: str, + organization: Organization, + alert_rule_serialized_response: AlertRuleSerializerResponse, + detector_serialized_response: DetectorSerializerResponse, + incident_serialized_response: DetailedIncidentSerializerResponse, +) -> None: + if notification_context.integration_id is None: + raise ValueError("Integration ID is None") + + if notification_context.target_identifier is None: + raise ValueError("Slack channel is None") + + attachment_info = incident_attachment_info( + organization=organization, + alert_context=alert_context, + metric_issue_context=metric_issue_context, + notification_uuid=notification_uuid, + referrer="metric_alert_slack", + ) + + chart_url = None + if ( + features.has("organizations:metric-alert-chartcuterie", organization) + and alert_rule_serialized_response + and incident_serialized_response + ): + try: + chart_url = build_metric_alert_chart( + organization=organization, + alert_rule_serialized_response=alert_rule_serialized_response, + snuba_query=metric_issue_context.snuba_query, + alert_context=alert_context, + open_period_context=open_period_context, + selected_incident_serialized=incident_serialized_response, + subscription=metric_issue_context.subscription, + detector_serialized_response=detector_serialized_response, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + data = MetricAlertNotificationData( + group_id=metric_issue_context.id, + organization_id=organization.id, + notification_uuid=notification_uuid, + action_id=notification_context.id, + open_period_context=open_period_context, + new_status=metric_issue_context.new_status.value, + title=attachment_info["title"], + title_link=attachment_info["title_link"], + text=attachment_info["text"], + chart_url=chart_url, + ) + + target = IntegrationNotificationTarget( + provider_key=NotificationProviderKey.SLACK, + resource_type=NotificationTargetResourceType.CHANNEL, + resource_id=notification_context.target_identifier, + integration_id=notification_context.integration_id, + organization_id=organization.id, + ) + + threading_options = ThreadingOptions( + thread_key=ThreadKey( + key_type=NotificationSource.METRIC_ALERT, + key_data={ + "action_id": notification_context.id, + "group_id": metric_issue_context.id, + "open_period_start": open_period_context.date_started.isoformat(), + }, + ), + reply_broadcast=(metric_issue_context.new_status == IncidentStatus.CRITICAL), + ) + + NotificationService[MetricAlertNotificationData](data=data).notify_sync( + targets=[target], threading_options=threading_options + ) + + @metric_alert_handler_registry.register(Action.Type.SLACK) class SlackMetricAlertHandler(BaseMetricAlertHandler): @classmethod @@ -58,17 +160,30 @@ def send_alert( }, ) - send_incident_alert_notification( - notification_context=notification_context, - alert_context=alert_context, - metric_issue_context=metric_issue_context, - open_period_context=open_period_context, - organization=organization, - notification_uuid=notification_uuid, - alert_rule_serialized_response=alert_rule_serialized_response, - incident_serialized_response=incident_serialized_response, - detector_serialized_response=detector_serialized_response, - ) + if NotificationService.has_access(organization, NotificationSource.METRIC_ALERT): + _send_via_notification_platform( + notification_context=notification_context, + alert_context=alert_context, + metric_issue_context=metric_issue_context, + open_period_context=open_period_context, + notification_uuid=notification_uuid, + organization=organization, + alert_rule_serialized_response=alert_rule_serialized_response, + detector_serialized_response=detector_serialized_response, + incident_serialized_response=incident_serialized_response, + ) + else: + send_incident_alert_notification( + notification_context=notification_context, + alert_context=alert_context, + metric_issue_context=metric_issue_context, + open_period_context=open_period_context, + organization=organization, + notification_uuid=notification_uuid, + alert_rule_serialized_response=alert_rule_serialized_response, + incident_serialized_response=incident_serialized_response, + detector_serialized_response=detector_serialized_response, + ) @metric_alert_handler_registry.register(Action.Type.SLACK_STAGING) diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py index 35a3a1129e9a..c64044b788fa 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py @@ -1,6 +1,12 @@ +from __future__ import annotations + import uuid from dataclasses import asdict +from typing import Any from unittest import mock +from unittest.mock import patch + +from slack_sdk.web import SlackResponse from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType from sentry.incidents.models.incident import IncidentStatus, TriggerStatus @@ -10,6 +16,7 @@ NotificationContext, OpenPeriodContext, ) +from sentry.integrations.types import IntegrationProviderSlug from sentry.models.activity import Activity from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import SlackMetricAlertHandler @@ -19,6 +26,8 @@ get_detector_serializer, ) from sentry.testutils.helpers.datetime import freeze_time +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.helpers.options import override_options from sentry.types.activity import ActivityType from sentry.workflow_engine.models import Action from sentry.workflow_engine.types import ActionInvocation, DetectorPriorityLevel, WorkflowEventData @@ -26,66 +35,118 @@ MetricAlertHandlerBase, ) +_HANDLER_PATH = "sentry.notifications.notification_action.metric_alert_registry.handlers.slack_metric_alert_handler" + -class TestSlackMetricAlertHandler(MetricAlertHandlerBase): +class TestSlackMetricAlertHandlerSendAlert(MetricAlertHandlerBase): def setUp(self) -> None: self.create_models() + self.integration, self.org_integration = self.create_provider_integration_for( + provider=IntegrationProviderSlug.SLACK, + organization=self.organization, + user=self.user, + name="test-slack", + metadata={"domain_name": "test-workspace.slack.com"}, + ) self.action = self.create_action( type=Action.Type.SLACK, - integration_id=1234567890, + integration_id=self.integration.id, config={ "target_identifier": "channel123", "target_display": "Channel 123", "target_type": ActionTarget.SPECIFIC, }, ) - self.handler = SlackMetricAlertHandler() - @mock.patch( - "sentry.notifications.notification_action.metric_alert_registry.handlers.slack_metric_alert_handler.send_incident_alert_notification" - ) - @freeze_time("2021-01-01 00:00:00") - def test_send_alert(self, mock_send_incident_alert_notification: mock.MagicMock) -> None: + def _make_send_alert_kwargs(self) -> dict[str, Any]: notification_context = NotificationContext.from_action_model(self.action) assert self.group_event.occurrence is not None assert self.group_event.occurrence.priority is not None - alert_context = AlertContext.from_workflow_engine_models( - self.detector, - self.evidence_data, - self.group_event.group.status, - DetectorPriorityLevel(self.group_event.occurrence.priority), - ) - metric_issue_context = MetricIssueContext.from_group_event( - self.group, - self.evidence_data, - DetectorPriorityLevel(self.group_event.occurrence.priority), - ) - open_period_context = OpenPeriodContext.from_group(self.group) - notification_uuid = str(uuid.uuid4()) - - self.handler.send_alert( + priority = DetectorPriorityLevel(self.group_event.occurrence.priority) + return dict( notification_context=notification_context, - alert_context=alert_context, - metric_issue_context=metric_issue_context, - open_period_context=open_period_context, + alert_context=AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group_event.group.status, + priority, + ), + metric_issue_context=MetricIssueContext.from_group_event( + self.group, self.evidence_data, priority + ), + open_period_context=OpenPeriodContext.from_group(self.group), trigger_status=TriggerStatus.ACTIVE, project=self.detector.project, organization=self.detector.project.organization, - notification_uuid=notification_uuid, + notification_uuid=str(uuid.uuid4()), ) - mock_send_incident_alert_notification.assert_called_once_with( - organization=self.detector.project.organization, - alert_context=alert_context, - notification_context=notification_context, - metric_issue_context=metric_issue_context, - open_period_context=open_period_context, + @override_options({"notifications.platform-rollout.internal-testing": {"metric-alert": 1.0}}) + @with_feature("organizations:notification-platform.internal-testing") + @patch("sentry.integrations.slack.integration.SlackSdkClient") + @freeze_time("2021-01-01 00:00:00") + def test_send_alert_via_np_sends_to_slack_channel( + self, mock_slack_client: mock.MagicMock + ) -> None: + mock_client_instance = mock_slack_client.return_value + mock_client_instance.chat_postMessage.return_value = SlackResponse( + client=mock_client_instance, + http_verb="POST", + api_url="https://slack.com/api/chat.postMessage", + req_args={}, + data={"ok": True, "ts": "123.456"}, + headers={}, + status_code=200, + ) + + self.handler.send_alert(**self._make_send_alert_kwargs()) + + mock_client_instance.chat_postMessage.assert_called_once() + call_kwargs = mock_client_instance.chat_postMessage.call_args.kwargs + assert call_kwargs["channel"] == "channel123" + blocks = call_kwargs["blocks"] + assert len(blocks) >= 1 + assert blocks[0]["type"] == "section" + assert blocks[0]["text"]["type"] == "mrkdwn" + + @patch(f"{_HANDLER_PATH}.send_incident_alert_notification") + @freeze_time("2021-01-01 00:00:00") + def test_send_alert_falls_back_to_legacy_when_no_access( + self, mock_send_incident: mock.MagicMock + ) -> None: + # No feature flag enabled → has_access returns False → legacy path + kwargs = self._make_send_alert_kwargs() + self.handler.send_alert(**kwargs) + + mock_send_incident.assert_called_once_with( + organization=kwargs["organization"], + alert_context=kwargs["alert_context"], + notification_context=kwargs["notification_context"], + metric_issue_context=kwargs["metric_issue_context"], + open_period_context=kwargs["open_period_context"], alert_rule_serialized_response=get_alert_rule_serializer(self.detector), incident_serialized_response=get_detailed_incident_serializer(self.open_period), detector_serialized_response=get_detector_serializer(self.detector), - notification_uuid=notification_uuid, + notification_uuid=kwargs["notification_uuid"], + ) + + +class TestSlackMetricAlertHandlerInvokeRegistry(MetricAlertHandlerBase): + """Tests for invoke_legacy_registry — verifies context extraction from GroupEvent and Activity.""" + + def setUp(self) -> None: + self.create_models() + self.action = self.create_action( + type=Action.Type.SLACK, + integration_id=1234567890, + config={ + "target_identifier": "channel123", + "target_display": "Channel 123", + "target_type": ActionTarget.SPECIFIC, + }, ) + self.handler = SlackMetricAlertHandler() @mock.patch( "sentry.notifications.notification_action.metric_alert_registry.SlackMetricAlertHandler.send_alert" @@ -158,31 +219,25 @@ def test_invoke_legacy_registry(self, mock_send_alert: mock.MagicMock) -> None: ) @freeze_time("2021-01-01 00:00:00") def test_invoke_legacy_registry_with_activity(self, mock_send_alert: mock.MagicMock) -> None: - # Create an Activity instance with evidence data and priority - activity_data = asdict(self.evidence_data) - activity = Activity( project=self.project, group=self.group, type=ActivityType.SET_RESOLVED.value, - data=activity_data, + data=asdict(self.evidence_data), ) activity.save() - # Create event data with Activity instead of GroupEvent event_data_with_activity = WorkflowEventData( event=activity, workflow_env=self.workflow.environment, group=self.group, ) - notification_uuid = str(uuid.uuid4()) - invocation = ActionInvocation( event_data=event_data_with_activity, action=self.action, detector=self.detector, - notification_uuid=notification_uuid, + notification_uuid=str(uuid.uuid4()), ) self.handler.invoke_legacy_registry(invocation) @@ -197,7 +252,6 @@ def test_invoke_legacy_registry_with_activity(self, mock_send_alert: mock.MagicM notification_uuid, ) = self.unpack_kwargs(mock_send_alert) - # Verify that the same data is extracted from Activity.data as from GroupEvent.occurrence.evidence_data self.assert_notification_context( notification_context, integration_id=1234567890, From c16202b3ae12919500496c19af7f0ecaf5f4c2bb Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Fri, 3 Apr 2026 10:01:05 -0700 Subject: [PATCH 07/35] fix(aci): Preselect current detector type when clicking create monitor (#112178) --- .../app/views/detectors/list/common/detectorListActions.tsx | 6 ++++-- static/app/views/detectors/list/cron.tsx | 2 +- static/app/views/detectors/list/metric.tsx | 2 +- static/app/views/detectors/list/mobileBuild.tsx | 2 +- static/app/views/detectors/list/uptime.tsx | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/static/app/views/detectors/list/common/detectorListActions.tsx b/static/app/views/detectors/list/common/detectorListActions.tsx index 0a14dccd1cdd..a37e6be31e86 100644 --- a/static/app/views/detectors/list/common/detectorListActions.tsx +++ b/static/app/views/detectors/list/common/detectorListActions.tsx @@ -5,6 +5,7 @@ import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {DetectorType} from 'sentry/types/workflowEngine/detectors'; import {useOrganization} from 'sentry/utils/useOrganization'; import {MonitorFeedbackButton} from 'sentry/views/detectors/components/monitorFeedbackButton'; import {makeMonitorCreatePathname} from 'sentry/views/detectors/pathnames'; @@ -13,9 +14,10 @@ import {useCanCreateDetector} from 'sentry/views/detectors/utils/useCanCreateDet interface DetectorListActionsProps { children?: React.ReactNode; + detectorType?: DetectorType; } -export function DetectorListActions({children}: DetectorListActionsProps) { +export function DetectorListActions({children, detectorType}: DetectorListActionsProps) { const organization = useOrganization(); const {selection} = usePageFilters(); @@ -29,7 +31,7 @@ export function DetectorListActions({children}: DetectorListActionsProps) { } diff --git a/static/app/views/detectors/list/cron.tsx b/static/app/views/detectors/list/cron.tsx index d291cfe7f475..ac861c9fa7b3 100644 --- a/static/app/views/detectors/list/cron.tsx +++ b/static/app/views/detectors/list/cron.tsx @@ -244,7 +244,7 @@ export default function CronDetectorsList() { } + actions={} title={TITLE} description={DESCRIPTION} docsUrl={DOCS_URL} diff --git a/static/app/views/detectors/list/metric.tsx b/static/app/views/detectors/list/metric.tsx index c0de7b94a409..063e6bfcf0d5 100644 --- a/static/app/views/detectors/list/metric.tsx +++ b/static/app/views/detectors/list/metric.tsx @@ -21,7 +21,7 @@ export default function MetricDetectorsList() { return ( } + actions={} title={TITLE} description={DESCRIPTION} docsUrl={DOCS_URL} diff --git a/static/app/views/detectors/list/mobileBuild.tsx b/static/app/views/detectors/list/mobileBuild.tsx index fe6914cf811e..daea09353a6e 100644 --- a/static/app/views/detectors/list/mobileBuild.tsx +++ b/static/app/views/detectors/list/mobileBuild.tsx @@ -22,7 +22,7 @@ export default function MobileBuildDetectorsList() { } + actions={} title={TITLE} description={DESCRIPTION} docsUrl={DOCS_URL} diff --git a/static/app/views/detectors/list/uptime.tsx b/static/app/views/detectors/list/uptime.tsx index dbbacafdcd32..fa385dbfc422 100644 --- a/static/app/views/detectors/list/uptime.tsx +++ b/static/app/views/detectors/list/uptime.tsx @@ -105,7 +105,7 @@ export default function UptimeDetectorsList() { } + actions={} title={TITLE} description={DESCRIPTION} docsUrl={DOCS_URL} From b9820f7421782f264262dfc535d2bfbb8e02c824 Mon Sep 17 00:00:00 2001 From: Lyn Nagara <1779792+lynnagara@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:02:13 -0700 Subject: [PATCH 08/35] ref(cells): clean up remaining instances of SENTRY_REGION_CONFIG and SENTRY_REGION (#112140) these can be safely removed now getsentry is updated --- src/sentry/conf/server.py | 3 --- src/sentry/testutils/cell.py | 13 +++---------- src/sentry/testutils/pytest/sentry.py | 3 --- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 37584bd50d46..12f13726a2d3 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -3273,9 +3273,6 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ] SENTRY_MONOLITH_REGION = SENTRY_CELLS[0]["name"] - # TODO(cells): remove after getsentry updated - SENTRY_REGION_CONFIG = SENTRY_CELLS - # Cross region RPC authentication RPC_SHARED_SECRET = [ "a-long-value-that-is-shared-but-also-secret", diff --git a/src/sentry/testutils/cell.py b/src/sentry/testutils/cell.py index 1000e20161d8..0c5ca00508d0 100644 --- a/src/sentry/testutils/cell.py +++ b/src/sentry/testutils/cell.py @@ -53,10 +53,7 @@ def swap_state( monolith_cell = cells[0] with override_settings(SENTRY_MONOLITH_REGION=monolith_cell.name): if local_cell: - # TODO(cells): Remove SENTRY_REGION once all references in getsentry tests updated - with override_settings( - SENTRY_LOCAL_CELL=local_cell.name, SENTRY_REGION=local_cell.name - ): + with override_settings(SENTRY_LOCAL_CELL=local_cell.name): yield else: yield @@ -73,10 +70,7 @@ def swap_state( @contextmanager def swap_to_default_cell(self) -> Generator[None]: """Swap to the monolith cell when entering cell mode.""" - # TODO(cells): Remove SENTRY_REGION once all references in getsentry tests updated - with override_settings( - SENTRY_LOCAL_CELL=self._default_cell.name, SENTRY_REGION=self._default_cell.name - ): + with override_settings(SENTRY_LOCAL_CELL=self._default_cell.name): yield @contextmanager @@ -85,8 +79,7 @@ def swap_to_cell_by_name(self, cell_name: str) -> Generator[None]: cell = self.get_cell_by_name(cell_name) if cell is None: raise Exception("specified swap cell not found") - # TODO(cells): Remove SENTRY_REGION once all references in getsentry tests updated - with override_settings(SENTRY_LOCAL_CELL=cell.name, SENTRY_REGION=cell.name): + with override_settings(SENTRY_LOCAL_CELL=cell.name): yield diff --git a/src/sentry/testutils/pytest/sentry.py b/src/sentry/testutils/pytest/sentry.py index 4ee03479684b..ce5a965fc419 100644 --- a/src/sentry/testutils/pytest/sentry.py +++ b/src/sentry/testutils/pytest/sentry.py @@ -88,9 +88,6 @@ def _configure_test_env_cells() -> None: settings.SENTRY_LOCAL_CELL = cell_name settings.SENTRY_MONOLITH_REGION = cell_name - # TODO(cells): Remove once all references in getsentry tests updated - settings.SENTRY_REGION = cell_name - # This not only populates the environment with the default cell, but also # ensures that a TestEnvCellDirectory instance is injected into global state. # See sentry.testutils.cell.get_test_env_directory, which relies on it. From 814605c965a60d6212a9b0e721807bd4a642307b Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 3 Apr 2026 10:07:00 -0700 Subject: [PATCH 09/35] ref(seer): Refactor hooks related to the preferred agent option (#112042) This makes this much easier to read/review and test. The stories are small and still work The bigs wins are a) moving a lot of logic out of the component and into hooks, b) and then leveraging useQuery and `select: () => ...` inside those hooks to format the data. Everything is more centered around `type PreferredAgent`, including the filename. i like how those things align tbh. And the tests are easier to read, and more plentiful, than before. --- .../overview/autofixOverviewSection.spec.tsx | 10 +- .../seer/overview/autofixOverviewSection.tsx | 163 +++--- .../overview/utils/seerPreferredAgent.spec.ts | 476 ++++++++++++++++++ .../seer/overview/utils/seerPreferredAgent.ts | 186 +++++++ .../settings/seer/seerAgentHooks.spec.tsx | 310 ------------ .../views/settings/seer/seerAgentHooks.tsx | 87 ---- 6 files changed, 739 insertions(+), 493 deletions(-) create mode 100644 static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts create mode 100644 static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx index 9c434d250687..e7f430a9c194 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx @@ -417,9 +417,13 @@ describe('autofixOverviewSection', () => { it('shows "No projects found" when there are no projects', async () => { renderSection([], {projects: []}); - // Each form section renders this text, so use findAllByText - const messages = await screen.findAllByText('No projects found'); - expect(messages.length).toBeGreaterThanOrEqual(2); + // Each form section renders this text; AgentNameForm only shows it after + // the integrations query resolves, so use waitFor to retry until both appear + await waitFor(() => { + expect(screen.getAllByText('No projects found').length).toBeGreaterThanOrEqual( + 2 + ); + }); }); it('shows "Your existing project uses Seer Agent" when 1 project uses preferred agent', async () => { diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index 4ce19c63456e..9e17fee181cb 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -15,21 +15,24 @@ import { bulkAutofixAutomationSettingsInfiniteOptions, type AutofixAutomationSettings, } from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; -import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {Placeholder} from 'sentry/components/placeholder'; import {IconSettings} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {useFetchAllPages} from 'sentry/utils/api/apiFetch'; -import {fetchMutation, useQuery} from 'sentry/utils/queryClient'; +import {fetchMutation} from 'sentry/utils/queryClient'; import {useInfiniteQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; import { - useAgentOptions, - useBulkMutateCreatePr, + getPreferredAgentMutationOptions, + useFetchPreferredAgent, + useFetchPreferredAgentOptions, useBulkMutateSelectedAgent, -} from 'sentry/views/settings/seer/seerAgentHooks'; +} from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; +import {useBulkMutateCreatePr} from 'sentry/views/settings/seer/seerAgentHooks'; export function useAutofixOverviewData() { const organization = useOrganization(); @@ -81,6 +84,9 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} const {projects} = useProjects(); const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {}; + const projectsIdsWithPreferredAgent = new Set( + projectsWithPreferredAgent.map(s => s.projectId) + ); const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false); const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false); @@ -108,7 +114,7 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} isBulkMutatingCreatePr={isBulkMutatingCreatePr} organization={organization} projects={projects} - projectsWithPreferredAgent={projectsWithPreferredAgent} + projectsIdsWithPreferredAgent={projectsIdsWithPreferredAgent} /> ; setIsBulkMutatingAgent: (value: boolean) => void; }) { - const {data: integrations} = useQuery( - organizationIntegrationsCodingAgents(organization) - ); - const rawAgentOptions = useAgentOptions({ - integrations: integrations?.integrations ?? [], - }).filter(option => option.value !== 'none'); - const codingAgentOptions = rawAgentOptions.map(option => ({ - value: option.value === 'seer' ? 'seer' : String(option.value.id), - label: option.label, - })); - - const codingAgentMutationOpts = mutationOptions({ - mutationFn: ({agentId}: {agentId: string}) => { - return fetchMutation({ - method: 'PUT', - url: `/organizations/${organization.slug}/`, - data: - agentId === 'seer' - ? { - defaultCodingAgent: agentId, - defaultCodingAgentIntegrationId: null, - } - : { - defaultCodingAgent: rawAgentOptions - .filter(option => option.value !== 'seer') - .find(option => option.value.id === agentId)?.value.provider, - defaultCodingAgentIntegrationId: agentId, - }, - }); - }, - onSuccess: updateOrganization, + const preferredAgent = useFetchPreferredAgent({organization}); + const codingAgentSelectOptions = useFetchPreferredAgentOptions({organization}); + const codingAgentMutationOptions = getPreferredAgentMutationOptions({organization}); + const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ + projects: projects.filter(p => !projectsIdsWithPreferredAgent.has(p.id)), }); - const preferredAgentValue = organization.defaultCodingAgentIntegrationId - ? String(organization.defaultCodingAgentIntegrationId) - : organization.defaultCodingAgent - ? organization.defaultCodingAgent - : 'seer'; - - const preferredAgentLabel = codingAgentOptions.find( - option => option.value === preferredAgentValue + const preferredAgentLabel = codingAgentSelectOptions.data?.find( + o => o.value === preferredAgent.data )?.label; - const preferredAgentIntegration = - preferredAgentValue === 'seer' - ? 'seer' - : rawAgentOptions - .filter(option => option.value !== 'seer') - .find(option => option.value.id === preferredAgentValue)?.value; - - const preferredAgentProjectIds = new Set( - projectsWithPreferredAgent.map(s => s.projectId) - ); - const projectsToUpdate = projects.filter(p => !preferredAgentProjectIds.has(p.id)); - - const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ - projects: projectsToUpdate, - }); + const initialValue = preferredAgent.data ? preferredAgent.data : ('seer' as const); return ( ()]), + })} + initialValue={initialValue} + mutationOptions={codingAgentMutationOptions} > {field => ( @@ -219,12 +181,24 @@ function AgentNameForm({ )} > - + {preferredAgent.isPending || codingAgentSelectOptions.isPending ? ( + + ) : codingAgentSelectOptions.isError ? ( + + {t('Failed to fetch coding agent options')} + + ) : ( + + a === b || + (typeof a === 'object' && typeof b === 'object' && a.id === b.id) + } + /> + )} @@ -236,13 +210,14 @@ function AgentNameForm({ !canWrite || isBulkMutatingAgent || isBulkMutatingCreatePr || - !preferredAgentIntegration || - projectsWithPreferredAgent.length === projects.length + preferredAgent.isPending || + codingAgentSelectOptions.isPending || + projectsIdsWithPreferredAgent.size === projects.length } onClick={async () => { - if (preferredAgentIntegration) { + if (preferredAgent.data) { setIsBulkMutatingAgent(true); - await bulkMutateSelectedAgent(preferredAgentIntegration, {}); + await bulkMutateSelectedAgent(preferredAgent.data); setIsBulkMutatingAgent(false); } else { addErrorMessage(t('No coding agent integration found')); @@ -252,25 +227,27 @@ function AgentNameForm({ {tn( 'Set for the existing project', 'Set for all existing projects', - projectsWithPreferredAgent.length + projectsIdsWithPreferredAgent.size )} - - {projects.length === 0 - ? t('No projects found') - : projects.length === 1 - ? projectsWithPreferredAgent.length === 1 - ? t('Your existing project uses %s', preferredAgentLabel) - : t('Your existing project does not use %s', preferredAgentLabel) - : projects.length === projectsWithPreferredAgent.length - ? t('All existing projects use %s', preferredAgentLabel) - : t( - '%s of %s existing projects use %s', - projectsWithPreferredAgent.length, - projects.length, - preferredAgentLabel - )} - + {preferredAgentLabel ? ( + + {projects.length === 0 + ? t('No projects found') + : projects.length === 1 + ? projectsIdsWithPreferredAgent.size === 1 + ? t('Your existing project uses %s', preferredAgentLabel) + : t('Your existing project does not use %s', preferredAgentLabel) + : projects.length === projectsIdsWithPreferredAgent.size + ? t('All existing projects use %s', preferredAgentLabel) + : t( + '%s of %s existing projects use %s', + projectsIdsWithPreferredAgent.size, + projects.length, + preferredAgentLabel + )} + + ) : null} )} diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts new file mode 100644 index 000000000000..8ea7f4d9a2fc --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -0,0 +1,476 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import * as indicators from 'sentry/actionCreators/indicator'; +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {OrganizationsStore} from 'sentry/stores/organizationsStore'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {useMutation} from 'sentry/utils/queryClient'; +import { + useBulkMutateSelectedAgent, + useFetchPreferredAgent, + useFetchPreferredAgentOptions, + getPreferredAgentMutationOptions, +} from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; + +describe('seerPreferredAgent', () => { + const organization = OrganizationFixture({slug: 'org-slug'}); + const project = ProjectFixture({slug: 'project-slug', id: '1'}); + + const integrations: CodingAgentIntegration[] = [ + {id: '42', name: 'Cursor', provider: 'cursor'}, + {id: '99', name: 'Claude Code', provider: 'claude_code'}, + ]; + + function mockIntegrationsEndpoint( + body: {integrations: CodingAgentIntegration[]} = {integrations} + ) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + method: 'GET', + body, + }); + } + + beforeEach(() => { + ProjectsStore.loadInitialData([project]); + }); + + afterEach(() => { + MockApiClient.clearMockResponses(); + jest.resetAllMocks(); + ProjectsStore.reset(); + }); + + describe('useFetchPreferredAgent', () => { + it('returns "seer" when no defaultCodingAgent or defaultCodingAgentIntegrationId', () => { + mockIntegrationsEndpoint(); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: null, + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + expect(result.current.data).toBe('seer'); + }); + + it('uses defaultCodingAgentIntegrationId when set', async () => { + mockIntegrationsEndpoint(); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgentIntegrationId: 42, + defaultCodingAgent: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({id: '42', name: 'Cursor'}); + }); + + it('falls back to defaultCodingAgent when no defaultCodingAgentIntegrationId', async () => { + mockIntegrationsEndpoint(); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: 'claude_code', + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + // 'claude_code' won't match any integration id (ids are '42', '99'), so returns 'seer' + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('seer'); + }); + + it('returns matching integration when defaultCodingAgent matches integration id', async () => { + mockIntegrationsEndpoint({ + integrations: [{id: 'cursor', name: 'Cursor', provider: 'cursor'}], + }); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({id: 'cursor', name: 'Cursor'}); + }); + + it('returns "seer" when no integration matches the value', async () => { + mockIntegrationsEndpoint({integrations: []}); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: 'nonexistent', + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('seer'); + }); + }); + + describe('useFetchPreferredAgentOptions', () => { + it('includes "seer" as first option plus integration options', async () => { + mockIntegrationsEndpoint(); + + const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + initialProps: {organization}, + organization, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const options = result.current.data!; + expect(options).toHaveLength(3); + expect(options[0]).toEqual({value: 'seer', label: expect.any(String)}); + expect(options[1]).toMatchObject({ + value: {id: '42', name: 'Cursor'}, + label: 'Cursor', + }); + expect(options[2]).toMatchObject({ + value: {id: '99', name: 'Claude Code'}, + label: 'Claude Code', + }); + }); + + it('filters out integrations without an id', async () => { + mockIntegrationsEndpoint({ + integrations: [ + {id: null, name: 'No Id', provider: 'other'}, + {id: '1', name: 'With Id', provider: 'cursor'}, + ], + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + initialProps: {organization}, + organization, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const options = result.current.data!; + expect(options).toHaveLength(2); // seer + one integration + expect(options[1]).toMatchObject({ + value: {id: '1', name: 'With Id'}, + label: 'With Id', + }); + }); + + it('returns only "seer" when there are no integrations', async () => { + mockIntegrationsEndpoint({integrations: []}); + + const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + initialProps: {organization}, + organization, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(result.current.data![0]).toEqual({value: 'seer', label: expect.any(String)}); + }); + }); + + describe('usePreferredAgentMutationOptions', () => { + function mockOrgPutRequest() { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + method: 'PUT', + body: OrganizationFixture({slug: organization.slug}), + }); + } + + beforeEach(() => { + OrganizationsStore.addOrReplace(organization); + }); + + it('sends PUT with seer payload when integration is "seer"', async () => { + mockIntegrationsEndpoint(); + const orgPutRequest = mockOrgPutRequest(); + + const options = getPreferredAgentMutationOptions({organization}); + const {result} = renderHookWithProviders(useMutation, { + initialProps: options, + }); + + act(() => { + result.current.mutateAsync({integration: 'seer'}); + }); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: null, + }, + }) + ); + }); + + it('sends PUT with integration payload when integration is a CodingAgentIntegration', async () => { + mockIntegrationsEndpoint(); + const orgPutRequest = mockOrgPutRequest(); + const integration: CodingAgentIntegration = { + id: '42', + name: 'Cursor', + provider: 'cursor', + }; + + const options = getPreferredAgentMutationOptions({organization}); + const {result} = renderHookWithProviders(useMutation, { + initialProps: options, + }); + + act(() => { + result.current.mutateAsync({integration}); + }); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: expect.objectContaining({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: '42', + }), + }) + ); + }); + }); + + describe('useBulkMutateSelectedAgent', () => { + const preference: SeerPreferencesResponse['preference'] = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + function setupMocks( + preferenceOverride: SeerPreferencesResponse['preference'] = preference + ) { + const seerPreferencesGetRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: preferenceOverride, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + const projectPutRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + body: project, + }); + const seerPreferencesPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }); + return {seerPreferencesGetRequest, projectPutRequest, seerPreferencesPostRequest}; + } + + it('sets autofixAutomationTuning to "medium" and clears handoff when integration is "seer"', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + data: {autofixAutomationTuning: 'medium'}, + }) + ); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: undefined, + }), + }) + ); + }); + + it('sets handoff payload when integration is a CodingAgentIntegration', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + const integration: CodingAgentIntegration = { + id: '42', + name: 'Cursor', + provider: 'cursor', + }; + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current(integration); + }); + + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + data: {autofixAutomationTuning: 'medium'}, + }) + ); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + handoff_point: 'root_cause', + integration_id: 42, + }), + }), + }) + ); + }); + + it('sets auto_create_pr true when automated_run_stopping_point is "open_pr"', async () => { + const {seerPreferencesPostRequest} = setupMocks({ + repositories: [], + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }); + const integration: CodingAgentIntegration = { + id: '42', + name: 'Cursor', + provider: 'cursor', + }; + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + await act(async () => { + await result.current(integration); + }); + + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + auto_create_pr: true, + }), + }), + }) + ); + }); + + it('updates ProjectsStore on success', async () => { + setupMocks(); + const updateSuccessSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(updateSuccessSpy).toHaveBeenCalledWith({ + id: project.id, + autofixAutomationTuning: 'medium', + }); + }); + + it('shows a generic error message when requests fail with non-429 errors', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: {preference, code_mapping_repos: []}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + statusCode: 500, + body: {detail: 'Internal Server Error'}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + statusCode: 500, + body: {detail: 'Internal Server Error'}, + }); + const addErrorMessageSpy = jest.spyOn(indicators, 'addErrorMessage'); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(addErrorMessageSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to update settings') + ); + }); + + it('shows a rate-limit error message when requests fail with 429', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: {preference, code_mapping_repos: []}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + statusCode: 429, + body: {detail: 'Too Many Requests'}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + statusCode: 429, + body: {detail: 'Too Many Requests'}, + }); + const addErrorMessageSpy = jest.spyOn(indicators, 'addErrorMessage'); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(addErrorMessageSpy).toHaveBeenCalledWith( + expect.stringContaining('Too many requests') + ); + }); + }); +}); diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts new file mode 100644 index 000000000000..a0f7fb6f1813 --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -0,0 +1,186 @@ +import {useCallback} from 'react'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {updateOrganization} from 'sentry/actionCreators/organizations'; +import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {makeProjectSeerPreferencesQueryKey} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import {PROVIDER_TO_HANDOFF_TARGET} from 'sentry/components/events/autofix/types'; +import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import {t} from 'sentry/locale'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {SelectValue} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import {processInChunks} from 'sentry/utils/array/procesInChunks'; +import { + fetchDataQuery, + fetchMutation, + useQueryClient, + mutationOptions, + useQuery, +} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +type PreferredAgent = 'seer' | CodingAgentIntegration; + +export function useFetchPreferredAgent({organization}: {organization: Organization}) { + const value = organization.defaultCodingAgentIntegrationId + ? String(organization.defaultCodingAgentIntegrationId) + : organization.defaultCodingAgent; + + const query = useQuery({ + ...organizationIntegrationsCodingAgents(organization), + enabled: value !== null && value !== 'seer', + select: data => + data.json.integrations?.find(i => i.id === String(value!)) ?? ('seer' as const), + }); + + if (value === null || value === 'seer') { + return { + ...query, + data: 'seer' as const, + isPending: false, + isSuccess: true, + status: 'success', + }; + } + return query; +} + +export function useFetchPreferredAgentOptions({ + organization, +}: { + organization: Organization; +}) { + return useQuery({ + ...organizationIntegrationsCodingAgents(organization), + select: data => { + return [ + {value: 'seer', label: t('Seer Agent')} as SelectValue, + ...(data.json.integrations ?? []) + .filter(integration => integration.id) + .map(integration => ({ + value: integration, + label: integration.name, + })), + ] as const; + }, + }); +} + +export function getPreferredAgentMutationOptions({ + organization, +}: { + organization: Organization; +}) { + return mutationOptions({ + mutationFn: ({integration}: {integration: PreferredAgent}) => { + return fetchMutation({ + method: 'PUT', + url: `/organizations/${organization.slug}/`, + data: + integration === 'seer' + ? { + defaultCodingAgent: integration, + defaultCodingAgentIntegrationId: null, + } + : { + defaultCodingAgent: integration.provider, + defaultCodingAgentIntegrationId: integration.id, + }, + }); + }, + onSuccess: updateOrganization, + }); +} + +export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }); + + return useCallback( + async (integration: PreferredAgent) => { + const results = await processInChunks({ + items: projects, + chunkSize: 15, + fn: async project => { + const [preferencesData] = await queryClient.fetchQuery({ + queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + queryFn: fetchDataQuery, + staleTime: 0, + }); + const preference = preferencesData?.preference; + + const handoff: ProjectSeerPreferences['automation_handoff'] = + integration !== 'seer' && integration + ? { + handoff_point: 'root_cause', + target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!, + integration_id: Number(integration.id), + auto_create_pr: preference?.automated_run_stopping_point === 'open_pr', + } + : undefined; + + return Promise.all([ + fetchMutation({ + method: 'PUT', + url: `/projects/${organization.slug}/${project.slug}/`, + data: {autofixAutomationTuning: 'medium'}, + }), + fetchMutation({ + method: 'POST', + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + data: { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: handoff, + }, + }), + ]); + }, + }); + + // Update store only for projects that succeeded + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + ProjectsStore.onUpdateSuccess({ + id: projects[i]!.id, + autofixAutomationTuning: 'medium', + }); + } + }); + + // Always invalidate to sync cache with whatever the server actually saved + queryClient.invalidateQueries({ + queryKey: autofixSettingsQueryOptions.queryKey, + }); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length) { + const has429 = failures.some( + r => r.reason instanceof RequestError && r.reason.status === 429 + ); + if (has429) { + addErrorMessage( + t('Too many requests. Please wait a moment before trying again.') + ); + } else { + addErrorMessage( + t( + 'Failed to update settings for %s project(s). Please try again.', + failures.length + ) + ); + } + } + }, + [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] + ); +} diff --git a/static/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx index bb72d9c17e76..5a34dbf3596a 100644 --- a/static/app/views/settings/seer/seerAgentHooks.spec.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx @@ -15,7 +15,6 @@ import {useQueryClient} from 'sentry/utils/queryClient'; import { useAgentOptions, useBulkMutateCreatePr, - useBulkMutateSelectedAgent, useMutateCreatePr, useMutateSelectedAgent, useSelectedAgentFromBulkSettings, @@ -618,315 +617,6 @@ describe('seerAgentHooks', () => { }); }); - describe('useBulkMutateSelectedAgent', () => { - const project1 = ProjectFixture({slug: 'project-slug', id: '1'}); - const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'}); - const projects = [project1, project2]; - - const basePreference: ProjectSeerPreferences = { - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }; - - function setupMocks(preference: ProjectSeerPreferences = basePreference) { - const mocks = projects.map(p => ({ - seerPreferencesGetRequest: MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'GET', - body: {preference, code_mapping_repos: []} satisfies SeerPreferencesResponse, - }), - projectPutRequest: MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/`, - method: 'PUT', - body: p, - }), - seerPreferencesPostRequest: MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }), - })); - return { - seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest), - projectPutRequests: mocks.map(m => m.projectPutRequest), - seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest), - }; - } - - function renderBulkMutateSelectedAgent() { - return renderHookWithProviders( - (props: {projects: typeof projects}) => { - const mutate = useBulkMutateSelectedAgent(props); - return {mutate}; - }, - { - initialProps: {projects}, - organization, - } - ); - } - - beforeEach(() => { - ProjectsStore.loadInitialData(projects); - }); - - it('sends correct API requests to all projects when integration is "seer"', async () => { - const {projectPutRequests, seerPreferencesPostRequests} = setupMocks(); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(projectPutRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((p, i) => { - expect(projectPutRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'medium'}, - }) - ); - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }), - }) - ); - }); - }); - - it('sends correct API requests to all projects when integration is a CodingAgentIntegration', async () => { - const {projectPutRequests, seerPreferencesPostRequests} = setupMocks(); - const integration: CodingAgentIntegration = { - id: '123', - name: 'Cursor', - provider: 'cursor', - }; - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - expect(projectPutRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((p, i) => { - expect(projectPutRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'medium'}, - }) - ); - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - automation_handoff: { - handoff_point: 'root_cause', - target: 'cursor_background_agent', - integration_id: 123, - auto_create_pr: false, - }, - }), - }) - ); - }); - }); - - it('sets auto_create_pr true when preference stopping point is open_pr', async () => { - const {seerPreferencesPostRequests} = setupMocks({ - ...basePreference, - automated_run_stopping_point: 'open_pr', - }); - const integration: CodingAgentIntegration = { - id: '456', - name: 'Cursor', - provider: 'cursor', - }; - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((_p, i) => { - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - data: expect.objectContaining({ - automation_handoff: expect.objectContaining({ - auto_create_pr: true, - }), - }), - }) - ); - }); - }); - - it('preserves repositories from each project preference', async () => { - const preferenceWithRepos: ProjectSeerPreferences = { - repositories: [ - {external_id: 'repo-1', name: 'my-repo', owner: 'my-org', provider: 'github'}, - ], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }; - const {seerPreferencesPostRequests} = setupMocks(preferenceWithRepos); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((_p, i) => { - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - data: expect.objectContaining({ - repositories: [ - { - external_id: 'repo-1', - name: 'my-repo', - owner: 'my-org', - provider: 'github', - }, - ], - }), - }) - ); - }); - }); - - it('updates ProjectsStore for all projects', async () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(storeSpy).toHaveBeenCalledTimes(2); - }); - - projects.forEach(p => { - expect(storeSpy).toHaveBeenCalledWith({ - id: p.id, - autofixAutomationTuning: 'medium', - }); - }); - }); - - it('updates ProjectsStore with "off" tuning for all projects when integration is "none"', async () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('none', {}); - }); - - await waitFor(() => { - expect(storeSpy).toHaveBeenCalledTimes(2); - }); - - projects.forEach(p => { - expect(storeSpy).toHaveBeenCalledWith({ - id: p.id, - autofixAutomationTuning: 'off', - }); - }); - }); - - it('calls onSuccess when all requests succeed', async () => { - setupMocks(); - const onSuccess = jest.fn(); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {onSuccess}); - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - it('calls onError when any request fails', async () => { - projects.forEach(p => { - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'GET', - body: { - preference: basePreference, - code_mapping_repos: [], - } satisfies SeerPreferencesResponse, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/`, - method: 'PUT', - statusCode: 500, - body: {}, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }); - }); - const onError = jest.fn(); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {onError}); - }); - - await waitFor(() => { - expect(onError).toHaveBeenCalledTimes(1); - }); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - }); - - it('does nothing when projects list is empty', async () => { - const emptyProjectsMutate = renderHookWithProviders( - () => useBulkMutateSelectedAgent({projects: []}), - {organization} - ); - const onSuccess = jest.fn(); - - act(() => { - emptyProjectsMutate.result.current('seer', {onSuccess}); - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('useBulkMutateCreatePr', () => { const project1 = ProjectFixture({slug: 'project-slug', id: '1'}); const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'}); diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx index 7aac636bce32..770ed07b8e3f 100644 --- a/static/app/views/settings/seer/seerAgentHooks.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.tsx @@ -214,93 +214,6 @@ export function useMutateSelectedAgent({project}: {project: Project}) { ); } -export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) { - const organization = useOrganization(); - const queryClient = useQueryClient(); - const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ - organization, - }); - - return useCallback( - async ( - integration: 'seer' | 'none' | CodingAgentIntegration, - {onSuccess, onError}: MutateOptions - ) => { - const results = await processInChunks({ - items: projects, - chunkSize: 15, - fn: async project => { - const [preferencesData] = await queryClient.fetchQuery({ - queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), - queryFn: fetchDataQuery, - staleTime: 0, - }); - const preference = preferencesData?.preference; - - const handoff: ProjectSeerPreferences['automation_handoff'] = - integration !== 'seer' && integration !== 'none' && integration - ? { - handoff_point: 'root_cause', - target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!, - integration_id: Number(integration.id), - auto_create_pr: preference?.automated_run_stopping_point === 'open_pr', - } - : undefined; - - return Promise.all([ - fetchMutation({ - method: 'PUT', - url: `/projects/${organization.slug}/${project.slug}/`, - data: {autofixAutomationTuning: integration === 'none' ? 'off' : 'medium'}, - }), - fetchMutation({ - method: 'POST', - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - data: { - repositories: preference?.repositories ?? [], - automated_run_stopping_point: preference?.automated_run_stopping_point, - automation_handoff: handoff, - }, - }), - ]); - }, - }); - - // Update store only for projects that succeeded - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - ProjectsStore.onUpdateSuccess({ - id: projects[i]!.id, - autofixAutomationTuning: integration === 'none' ? 'off' : 'medium', - }); - } - }); - - // Always invalidate to sync cache with whatever the server actually saved - queryClient.invalidateQueries({ - queryKey: autofixSettingsQueryOptions.queryKey, - }); - - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length === 0) { - onSuccess?.(); - } else { - const has429 = failures.some( - r => r.reason instanceof RequestError && r.reason.status === 429 - ); - if (has429) { - addErrorMessage( - t('Too many requests. Please wait a moment before trying again.') - ); - } else { - onError?.(new Error('Failed to update agent setting')); - } - } - }, - [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] - ); -} - export function useBulkMutateCreatePr({projects}: {projects: Project[]}) { const organization = useOrganization(); const queryClient = useQueryClient(); From ed8df887131097639be86aa2d1116d9c6a5ed898 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 3 Apr 2026 10:13:46 -0700 Subject: [PATCH 10/35] fix(supergroups): Use placeholder data to avoid loading flash on group changes (#112202) When a group is archived and a new one backfills in, keep the previous supergroup data visible while the new query fetches in the background. Only show a loading state when navigating to an entirely new page of results (no overlap with previous group IDs). Co-authored-by: Claude Opus 4.6 --- .../utils/supergroup/useSuperGroups.spec.tsx | 78 +++++++++++++++++++ .../app/utils/supergroup/useSuperGroups.tsx | 29 ++++--- 2 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 static/app/utils/supergroup/useSuperGroups.spec.tsx diff --git a/static/app/utils/supergroup/useSuperGroups.spec.tsx b/static/app/utils/supergroup/useSuperGroups.spec.tsx new file mode 100644 index 000000000000..123f5cc90573 --- /dev/null +++ b/static/app/utils/supergroup/useSuperGroups.spec.tsx @@ -0,0 +1,78 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useSuperGroups} from 'sentry/utils/supergroup/useSuperGroups'; +import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; + +const organization = OrganizationFixture({features: ['top-issues-ui']}); +const API_URL = `/organizations/${organization.slug}/seer/supergroups/by-group/`; + +function makeSupergroup(overrides: Partial = {}): SupergroupDetail { + return { + id: 1, + title: 'Test Supergroup', + summary: 'A test supergroup', + error_type: 'TypeError', + code_area: 'frontend', + group_ids: [1, 2, 3], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('useSuperGroups', () => { + it('does not show loading state when archiving a group backfills a new one', async () => { + const supergroup = makeSupergroup({group_ids: [1, 2, 3]}); + const mockRequest = MockApiClient.addMockResponse({ + url: API_URL, + body: {data: [supergroup]}, + }); + + const {result, rerender} = renderHookWithProviders( + (props: {groupIds: string[]}) => useSuperGroups(props.groupIds), + { + organization, + initialProps: {groupIds: ['1', '2', '3']}, + } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockRequest).toHaveBeenCalledTimes(1); + + // Group '3' is archived and removed from the list + rerender({groupIds: ['1', '2']}); + expect(result.current.isLoading).toBe(false); + expect(mockRequest).toHaveBeenCalledTimes(1); + + // A new group backfills into the list — should refetch in the background + rerender({groupIds: ['1', '2', '4']}); + expect(result.current.isLoading).toBe(false); + + await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(2)); + }); + + it('shows loading state when navigating to an entirely new page', async () => { + const supergroup = makeSupergroup({group_ids: [1, 2, 3]}); + const mockRequest = MockApiClient.addMockResponse({ + url: API_URL, + body: {data: [supergroup]}, + }); + + const {result, rerender} = renderHookWithProviders( + (props: {groupIds: string[]}) => useSuperGroups(props.groupIds), + { + organization, + initialProps: {groupIds: ['1', '2', '3']}, + } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockRequest).toHaveBeenCalledTimes(1); + + // Navigate to a completely different page of results + rerender({groupIds: ['4', '5', '6']}); + expect(result.current.isLoading).toBe(true); + }); +}); diff --git a/static/app/utils/supergroup/useSuperGroups.tsx b/static/app/utils/supergroup/useSuperGroups.tsx index 8385fb02ac15..f80c2b444615 100644 --- a/static/app/utils/supergroup/useSuperGroups.tsx +++ b/static/app/utils/supergroup/useSuperGroups.tsx @@ -19,20 +19,21 @@ export function useSuperGroups(groupIds: string[]): { const organization = useOrganization(); const requestedGroupIdsRef = useRef(groupIds); const hasTopIssuesUI = organization.features.includes('top-issues-ui'); - const shouldReuseRequestedGroupIds = useMemo(() => { - const requestedGroupIds = requestedGroupIdsRef.current; - if (groupIds.length === 0 || requestedGroupIds.length < groupIds.length) { - return false; - } + const previousRequestedGroupIds = requestedGroupIdsRef.current; - const requestedGroupIdSet = new Set(requestedGroupIds); - return groupIds.every(groupId => requestedGroupIdSet.has(groupId)); + // Stabilize the query key: if the new groupIds are a subset of what we + // already requested (groups were removed), reuse the previous set to + // avoid a redundant refetch. + const requestedGroupIds = useMemo(() => { + const prev = requestedGroupIdsRef.current; + if (groupIds.length === 0 || prev.length < groupIds.length) { + return groupIds; + } + const prevSet = new Set(prev); + return groupIds.every(id => prevSet.has(id)) ? prev : groupIds; }, [groupIds]); - const requestedGroupIds = shouldReuseRequestedGroupIds - ? requestedGroupIdsRef.current - : groupIds; requestedGroupIdsRef.current = requestedGroupIds; const enabled = hasTopIssuesUI && requestedGroupIds.length > 0; @@ -50,6 +51,13 @@ export function useSuperGroups(groupIds: string[]): { { staleTime: 30_000, enabled, + placeholderData: previousData => { + if (!previousData) { + return undefined; + } + const prevSet = new Set(previousRequestedGroupIds); + return groupIds.some(id => prevSet.has(id)) ? previousData : undefined; + }, } ); @@ -57,7 +65,6 @@ export function useSuperGroups(groupIds: string[]): { if (!response?.data) { return {}; } - const result: SupergroupLookup = Object.fromEntries(groupIds.map(id => [id, null])); for (const sg of response.data) { for (const groupId of sg.group_ids) { From 371be3fce99a2c48b61b17382ace969ea41bdb8d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 3 Apr 2026 10:33:26 -0700 Subject: [PATCH 11/35] fix(github): Add `sync_repos_on_install_change` to TASKWORKER_IMPORTS (#112201) This test didn't run in my branch, so fixing this now. --- src/sentry/conf/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 12f13726a2d3..057cff896627 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -882,6 +882,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.integrations.github.tasks.codecov_account_unlink", "sentry.integrations.github.tasks.link_all_repos", "sentry.integrations.github.tasks.pr_comment", + "sentry.integrations.github.tasks.sync_repos_on_install_change", "sentry.integrations.gitlab.tasks", "sentry.integrations.jira.tasks", "sentry.integrations.opsgenie.tasks", From ef0e343c638d8e42bab34d7ef84e21f3871240e5 Mon Sep 17 00:00:00 2001 From: Jeffrey Hung <17494876+Jeffreyhung@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:41:31 -0700 Subject: [PATCH 12/35] fix(vulnerability): Update action-add-labels version (#112207) Patched vulnerabilities in the action https://github.com/getsentry/action-add-labels --- .github/workflows/label-pullrequest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/label-pullrequest.yml b/.github/workflows/label-pullrequest.yml index 48cfe4f909e6..22c7cd534255 100644 --- a/.github/workflows/label-pullrequest.yml +++ b/.github/workflows/label-pullrequest.yml @@ -24,13 +24,13 @@ jobs: filters: .github/file-filters.yml - name: Add frontend label - uses: getsentry/action-add-labels@54d0cba498c1eaf8bd34985d715504d1b6e2935f + uses: getsentry/action-add-labels@ca568508c6e91387909cb3661e4cd965aa2f3a89 if: steps.changes.outputs.frontend_all == 'true' with: labels: 'Scope: Frontend' - name: Add backend label - uses: getsentry/action-add-labels@54d0cba498c1eaf8bd34985d715504d1b6e2935f + uses: getsentry/action-add-labels@ca568508c6e91387909cb3661e4cd965aa2f3a89 if: steps.changes.outputs.backend_src == 'true' with: labels: 'Scope: Backend' From a50ff759067f41e04140b66110e42611fbcd573c Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:06:38 -0700 Subject: [PATCH 13/35] fix(autofix): Only write handoff.auto_create_pr ProjectOption if not default (#112208) When handoff is not None and auto_create_pr is False (its registered default), delete the option rather than writing it. The other handoff fields are fine because they can't be None if handoff is not None. --- src/sentry/seer/autofix/utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index f0bf410ef221..1e72a58fc7e9 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -561,9 +561,12 @@ def _write_preference_project_options(project: Project, preference: SeerProjectP project.update_option( "sentry:seer_automation_handoff_integration_id", handoff.integration_id ) - project.update_option( - "sentry:seer_automation_handoff_auto_create_pr", handoff.auto_create_pr - ) + if handoff.auto_create_pr: + project.update_option( + "sentry:seer_automation_handoff_auto_create_pr", handoff.auto_create_pr + ) + else: + project.delete_option("sentry:seer_automation_handoff_auto_create_pr") else: project.delete_option("sentry:seer_automation_handoff_point") project.delete_option("sentry:seer_automation_handoff_target") From 76eaf72e074e04acaeb2116c2469145b1ee2bf0b Mon Sep 17 00:00:00 2001 From: Mihir-Mavalankar Date: Fri, 3 Apr 2026 11:13:14 -0700 Subject: [PATCH 14/35] feat(seer): Send structured LLMContext JSON as on_page_context (#112200) + When on dashboard pages with the context-engine-structured-page-context feature flag enabled, send JSON.stringify(getLLMContext()) instead of an ASCII DOM screenshot as on_page_context. The backend (already merged) detects JSON and converts it to markdown before forwarding to Seer. + Uses an explicit STRUCTURED_CONTEXT_ROUTES set for route matching rather than string includes, making it easy to extend as more pages register LLMContext providers. --------- Co-authored-by: Claude Opus 4.6 --- .../hooks/useSeerExplorer.spec.tsx | 76 +++++++++++++++++++ .../seerExplorer/hooks/useSeerExplorer.tsx | 25 +++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx index 41582af913df..c041ea0b3452 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx @@ -2,12 +2,22 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; +import {usePageReferrer} from 'sentry/views/seerExplorer/utils'; + import {useSeerExplorer} from './useSeerExplorer'; +jest.mock('sentry/views/seerExplorer/utils', () => ({ + ...jest.requireActual('sentry/views/seerExplorer/utils'), + usePageReferrer: jest.fn(), +})); + describe('useSeerExplorer', () => { beforeEach(() => { MockApiClient.clearMockResponses(); sessionStorage.clear(); + (usePageReferrer as jest.Mock).mockReturnValue({ + getPageReferrer: () => '/issues/', + }); }); const organization = OrganizationFixture({ @@ -97,6 +107,72 @@ describe('useSeerExplorer', () => { ); }); + it('sends structured JSON on dashboard page with feature flag', async () => { + (usePageReferrer as jest.Mock).mockReturnValue({ + getPageReferrer: () => '/dashboard/:dashboardId/', + }); + const org = OrganizationFixture({ + features: ['seer-explorer', 'context-engine-structured-page-context'], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/seer/explorer-chat/`, + method: 'GET', + body: {session: null}, + }); + const postMock = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/seer/explorer-chat/`, + method: 'POST', + body: {run_id: 1}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/seer/explorer-chat/1/`, + method: 'GET', + body: {session: {blocks: [], run_id: 1, status: 'completed', updated_at: ''}}, + }); + + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization: org, + }); + await act(async () => { + await result.current.sendMessage('q'); + }); + + const ctx = postMock.mock.calls[0][1].data.on_page_context; + expect(JSON.parse(ctx)).toHaveProperty('nodes'); + }); + + it('falls back to ASCII screenshot on non-dashboard page', async () => { + const org = OrganizationFixture({ + features: ['seer-explorer', 'context-engine-structured-page-context'], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/seer/explorer-chat/`, + method: 'GET', + body: {session: null}, + }); + const postMock = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/seer/explorer-chat/`, + method: 'POST', + body: {run_id: 1}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/seer/explorer-chat/1/`, + method: 'GET', + body: {session: {blocks: [], run_id: 1, status: 'completed', updated_at: ''}}, + }); + + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization: org, + }); + await act(async () => { + await result.current.sendMessage('q'); + }); + + // usePageReferrer returns '/issues/' by default (from beforeEach) — not in STRUCTURED_CONTEXT_ROUTES + const ctx = postMock.mock.calls[0][1].data.on_page_context; + expect(() => JSON.parse(ctx)).toThrow(); + }); + it('handles API errors gracefully', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/explorer-chat/`, diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 3f7fe30d3204..d52ba1642565 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -1,4 +1,5 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import * as Sentry from '@sentry/react'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -15,6 +16,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; +import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext'; import {useAsciiSnapshot} from 'sentry/views/seerExplorer/hooks/useAsciiSnapshot'; import type {Block, RepoPRState} from 'sentry/views/seerExplorer/types'; import {useExplorerPanel} from 'sentry/views/seerExplorer/useExplorerPanel'; @@ -49,6 +51,9 @@ type SeerExplorerChatResponse = { const POLL_INTERVAL = 500; // Poll every 500ms +/** Routes where the LLMContext tree provides structured page context. */ +const STRUCTURED_CONTEXT_ROUTES = new Set(['/dashboard/:dashboardId/']); + const OPTIMISTIC_ASSISTANT_TEXTS = [ 'Looking around...', 'One sec...', @@ -110,6 +115,7 @@ export const useSeerExplorer = () => { const organization = useOrganization({allowNull: true}); const orgSlug = organization?.slug; const captureAsciiSnapshot = useAsciiSnapshot(); + const {getLLMContext} = useLLMContext(); const [overrideCtxEngEnable, setOverrideCtxEngEnable] = useState(true); const [runId, setRunId] = useSessionStorage( @@ -184,8 +190,22 @@ export const useSeerExplorer = () => { // explicitRunId: undefined = use current runId, null = force new run, number = use that run const effectiveRunId = explicitRunId === undefined ? runId : explicitRunId; - // Capture a coarse ASCII screenshot of the user's screen for extra context - const screenshot = captureAsciiSnapshot?.(); + // Send structured LLMContext JSON on supported pages when the feature flag + // is enabled; fall back to a coarse ASCII screenshot otherwise. + let screenshot: string | undefined; + if ( + STRUCTURED_CONTEXT_ROUTES.has(getPageReferrer()) && + organization?.features.includes('context-engine-structured-page-context') + ) { + try { + screenshot = JSON.stringify(getLLMContext()); + } catch (e) { + Sentry.captureException(e); + screenshot = captureAsciiSnapshot?.(); + } + } else { + screenshot = captureAsciiSnapshot?.(); + } setWaitingForResponse(true); setWasJustInterrupted(false); @@ -279,6 +299,7 @@ export const useSeerExplorer = () => { apiData, deletedFromIndex, captureAsciiSnapshot, + getLLMContext, setRunId, getPageReferrer, organization, From 167e0ea8bf4808056d68259222ccb9a33562ac7f Mon Sep 17 00:00:00 2001 From: Charles Paul Date: Fri, 3 Apr 2026 11:19:50 -0700 Subject: [PATCH 15/35] fix(typing): Type post-process (#112203) Just a bunch of missing types. Added it to strictlist at the file-level (rather than the usual module level) because I think this file is important and tasks is too big to do all at once. --- pyproject.toml | 1 + .../organization_integration_request.py | 7 +- src/sentry/plugins/base/manager.py | 2 +- src/sentry/plugins/interfaces/releasehook.py | 4 +- src/sentry/tasks/post_process.py | 78 ++++++++++--------- src/sentry/web/frontend/release_webhook.py | 1 + 6 files changed, 54 insertions(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 46b1f3728e61..5bf7ba311304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -683,6 +683,7 @@ module = [ "sentry.tasks.codeowners.*", "sentry.tasks.commit_context", "sentry.tasks.on_demand_metrics", + "sentry.tasks.post_process", "sentry.tasks.reprocessing2", "sentry.tasks.seer.delete_seer_grouping_records", "sentry.tasks.store", diff --git a/src/sentry/integrations/api/endpoints/organization_integration_request.py b/src/sentry/integrations/api/endpoints/organization_integration_request.py index de30dc938e3c..c7fb4b2f2e0d 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_request.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_request.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from rest_framework.request import Request from rest_framework.response import Response @@ -15,8 +17,11 @@ from sentry.plugins.base import plugins from sentry.sentry_apps.services.app import app_service +if TYPE_CHECKING: + from django.utils.functional import _StrPromise + -def get_provider_name(provider_type: str, provider_slug: str) -> str | None: +def get_provider_name(provider_type: str, provider_slug: str) -> str | _StrPromise | None: """ The things that users think of as "integrations" are actually three different things: integrations, plugins, and sentryapps. A user requesting diff --git a/src/sentry/plugins/base/manager.py b/src/sentry/plugins/base/manager.py index 448432b4ce9a..2cab7ce74579 100644 --- a/src/sentry/plugins/base/manager.py +++ b/src/sentry/plugins/base/manager.py @@ -61,7 +61,7 @@ def for_project(self, project, version=1) -> Generator[Plugin | Plugin2]: continue yield plugin - def get(self, slug): + def get(self, slug: str) -> Plugin | Plugin2: for plugin in self.all(version=None): if plugin.slug == slug: return plugin diff --git a/src/sentry/plugins/interfaces/releasehook.py b/src/sentry/plugins/interfaces/releasehook.py index a8cd3abd0d42..65de6fec8795 100644 --- a/src/sentry/plugins/interfaces/releasehook.py +++ b/src/sentry/plugins/interfaces/releasehook.py @@ -3,9 +3,9 @@ import logging from django.db import IntegrityError, router, transaction +from django.http import HttpRequest from django.http.response import HttpResponseBase from django.utils import timezone -from rest_framework.request import Request from sentry.exceptions import HookValidationError from sentry.models.activity import Activity @@ -79,5 +79,5 @@ def finish_release(self, version, **values): ) self.set_refs(release=release, **values) - def handle(self, request: Request) -> HttpResponseBase | None: + def handle(self, request: HttpRequest) -> HttpResponseBase | None: raise NotImplementedError diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index d9076e4616d6..f4d8390e65c6 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -6,7 +6,7 @@ from collections.abc import MutableMapping, Sequence from datetime import datetime from time import time -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, Callable, TypedDict import sentry_sdk from django.conf import settings @@ -72,7 +72,7 @@ class PostProcessJob(TypedDict, total=False): has_escalated: bool -def _should_send_error_created_hooks(project): +def _should_send_error_created_hooks(project: Project) -> bool: from sentry.models.organization import Organization from sentry.sentry_apps.models.servicehook import ServiceHook @@ -94,33 +94,34 @@ def _should_send_error_created_hooks(project): cache_value = 1 if result else 0 cache.set(cache_key, cache_value, 60) - return result + # We cache either 0 or 1; cast to bool is purely for typing. + return bool(result) -def should_write_event_stats(event: Event | GroupEvent): +def should_write_event_stats(event: Event | GroupEvent) -> bool: # For now, we only want to write these stats for error events. If we start writing them for # other event types we'll throw off existing stats and potentially cause various alerts to fire. # We might decide to write these stats for other event types later, either under different keys # or with differentiating tags. - return ( + return bool( event.group and event.group.issue_category == GroupCategory.ERROR and event.group.platform is not None ) -def format_event_platform(event: Event | GroupEvent): +def format_event_platform(event: Event | GroupEvent) -> str | None: if not event.group: logger.error( "Group not found on event during formatting", extra={"event_id": event.event_id} ) - return + return None if not event.group.platform: logger.error( "Platform not found on group during formatting", extra={"event_id": event.event_id, "group_id": event.group.id}, ) - return + return None platform = event.group.platform return platform.split("-", 1)[0].split("_", 1)[0] @@ -152,7 +153,9 @@ def _capture_group_stats(job: PostProcessJob) -> None: @sentry_sdk.trace -def should_issue_owners_ratelimit(project_id: int, group_id: int, organization_id: int | None): +def should_issue_owners_ratelimit( + project_id: int, group_id: int, organization_id: int | None +) -> bool: """ Make sure that we do not accept more groups than the enforced_limit at the project level. """ @@ -182,7 +185,7 @@ def should_issue_owners_ratelimit(project_id: int, group_id: int, organization_i @metrics.wraps("post_process.handle_owner_assignment") @sentry_sdk.trace -def handle_owner_assignment(job): +def handle_owner_assignment(job: PostProcessJob) -> None: """ The handle_owner_assignment task attempts to find issue owners for a group. We call `ProjectOwnership.get_issue_owners` to find issue owners, and then @@ -295,7 +298,7 @@ def handle_owner_assignment(job): @sentry_sdk.trace -def handle_invalid_group_owners(group): +def handle_invalid_group_owners(group: Group) -> None: from sentry.models.groupowner import GroupOwner, GroupOwnerType invalid_group_owners = GroupOwner.objects.filter( @@ -315,7 +318,7 @@ def handle_group_owners( project: Project, group: Group, issue_owners: Sequence[tuple[Rule, Sequence[Team | RpcUser], str]], -): +) -> None: """ Stores group owners generated by `ProjectOwnership.get_issue_owners` in the `GroupOwner` model, and handles any diffing/changes of which owners we're keeping. @@ -343,7 +346,7 @@ def handle_group_owners( group=group, type__in=[GroupOwnerType.OWNERSHIP_RULE.value, GroupOwnerType.CODEOWNERS.value], ) - new_owners: dict = {} + new_owners: dict[tuple[type, int | None, str], list[Rule]] = {} for rule, owners, source in issue_owners: for owner in owners: # Can potentially have multiple rules pointing to the same owner @@ -435,7 +438,7 @@ def handle_group_owners( pass -def update_existing_attachments(job): +def update_existing_attachments(job: PostProcessJob) -> None: """ Attaches the group_id to all event attachments that were either: @@ -451,7 +454,7 @@ def update_existing_attachments(job): ) -def fetch_buffered_group_stats(group): +def fetch_buffered_group_stats(group: Group) -> None: """ Populates `times_seen_pending` with the number of buffered increments to `times_seen` for this group. `times_seen_with_pending` can subsequently be used as the total times seen, @@ -485,17 +488,17 @@ def should_retry_fetch(attempt: int, e: Exception) -> bool: silo_mode=SiloMode.CELL, ) def post_process_group( - is_new, - is_regression, - is_new_group_environment, - cache_key, - group_id=None, + is_new: bool, + is_regression: bool | None, + is_new_group_environment: bool, + cache_key: str | None, + group_id: int | None = None, occurrence_id: str | None = None, *, project_id: int, eventstream_type: str | None = None, - **kwargs, -): + **kwargs: Any, +) -> None: """ Fires post processing hooks for a group. """ @@ -513,6 +516,7 @@ def post_process_group( # We use the data being present/missing in the processing store # to ensure that we don't duplicate work should the forwarding consumers # need to rewind history. + assert cache_key is not None data = event_processing_store.get(cache_key) if not data: logger.info( @@ -603,7 +607,7 @@ def get_event_raise_exception() -> Event: group_state: GroupState = { "id": group_id, "is_new": is_new, - "is_regression": is_regression, + "is_regression": bool(is_regression), "is_new_group_environment": is_new_group_environment, } @@ -885,7 +889,7 @@ def process_snoozes(job: PostProcessJob) -> None: def process_replay_link(job: PostProcessJob) -> None: - def _get_replay_id(event): + def _get_replay_id(event: GroupEvent) -> str | None: # replay ids can either come as a context, or a tag. # right now they come as a context on non-js events, # and javascript transaction (through DSC context) @@ -1246,7 +1250,7 @@ def process_similarity(job: PostProcessJob) -> None: safe_execute(similarity.record, event.project, [event]) -def fire_error_processed(job: PostProcessJob): +def fire_error_processed(job: PostProcessJob) -> None: if job["is_reprocessed"]: return event = job["event"] @@ -1258,7 +1262,7 @@ def fire_error_processed(job: PostProcessJob): ) -def process_processing_errors_eap(job: PostProcessJob): +def process_processing_errors_eap(job: PostProcessJob) -> None: if job["is_reprocessed"]: return @@ -1276,13 +1280,13 @@ def process_processing_errors_eap(job: PostProcessJob): produce_processing_errors_to_eap(event.project, event.data, processing_errors) -def process_processing_issue_detection(job: PostProcessJob): +def process_processing_issue_detection(job: PostProcessJob) -> None: from sentry.processing_errors.detection import detect_processing_issues detect_processing_issues(job) -def sdk_crash_monitoring(job: PostProcessJob): +def sdk_crash_monitoring(job: PostProcessJob) -> None: from sentry.utils.sdk_crashes.sdk_crash_detection import sdk_crash_detection if job["is_reprocessed"]: @@ -1302,7 +1306,7 @@ def sdk_crash_monitoring(job: PostProcessJob): sdk_crash_detection.detect_sdk_crash(event=event, configs=configs) -def plugin_post_process_group(plugin_slug, event, **kwargs): +def plugin_post_process_group(plugin_slug: str, event: GroupEvent, **kwargs: Any) -> None: """ Fires post processing hooks for a group. """ @@ -1324,8 +1328,10 @@ def plugin_post_process_group(plugin_slug, event, **kwargs): logger.warning("post_process.process_error", extra={"exception": e}) -def feedback_filter_decorator(func): - def wrapper(job): +def feedback_filter_decorator( + func: Callable[[PostProcessJob], None], +) -> Callable[[PostProcessJob], None]: + def wrapper(job: PostProcessJob) -> None: if not should_postprocess_feedback(job): return return func(job) @@ -1425,7 +1431,7 @@ def link_event_to_user_report(job: PostProcessJob) -> None: MIN_EVENTS_FOR_NEW_ESCALATION = 10 -def detect_new_escalation(job: PostProcessJob): +def detect_new_escalation(job: PostProcessJob) -> None: """ Detects whether a new issue is escalating. New issues are issues less than MAX_NEW_ESCALATION_AGE_HOURS hours old. @@ -1497,7 +1503,7 @@ def detect_new_escalation(job: PostProcessJob): return -def detect_base_urls_for_uptime(job: PostProcessJob): +def detect_base_urls_for_uptime(job: PostProcessJob) -> None: from sentry.uptime.autodetect.detector import autodetect_base_url_for_project url = get_path(job["event"].data, "request", "url") @@ -1578,7 +1584,9 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: ) -GROUP_CATEGORY_POST_PROCESS_PIPELINE = { +GROUP_CATEGORY_POST_PROCESS_PIPELINE: dict[ + GroupCategory, list[Callable[[PostProcessJob], None]] +] = { GroupCategory.ERROR: [ _capture_group_stats, process_snoozes, @@ -1620,7 +1628,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: ], } -GENERIC_POST_PROCESS_PIPELINE = [ +GENERIC_POST_PROCESS_PIPELINE: list[Callable[[PostProcessJob], None]] = [ process_snoozes, process_inbox_adds, kick_off_seer_automation, diff --git a/src/sentry/web/frontend/release_webhook.py b/src/sentry/web/frontend/release_webhook.py index 3b5cfc9a059e..e5d8093e9d76 100644 --- a/src/sentry/web/frontend/release_webhook.py +++ b/src/sentry/web/frontend/release_webhook.py @@ -109,6 +109,7 @@ def post(self, request: HttpRequest, plugin_id, project_id, signature) -> HttpRe return HttpResponse(status=403) cls = plugin.get_release_hook() + assert cls is not None hook = cls(project) try: hook.handle(request) From cf435d4cc0bfb1ee4357126e6ed5f4a65bf93b58 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:20:20 -0700 Subject: [PATCH 16/35] ref(utils): Remove dead execute() function (#112199) Remove the `execute()` function from `sentry/utils/concurrent.py`. It spawns a bare `threading.Thread` that does not propagate `contextvars`, making it a potential hazard for ViewerContext propagation work. Its last caller was the legacy pre-Arroyo Kafka post-process forwarder, removed in Feb 2023 (#44631). No code in the Sentry codebase imports or calls it today. Removing it rather than fixing its context propagation since `ContextPropagatingThreadPoolExecutor` is the blessed pattern (enforced by S016 lint rule). One less function that could silently lose context if rediscovered. Agent transcript: https://claudescope.sentry.dev/share/-gVA8oDYrqoHR4FJeNP_snlwGf7vKDU_cG4AV4FH4pA --- src/sentry/utils/concurrent.py | 21 --------------------- tests/sentry/utils/test_concurrent.py | 9 --------- 2 files changed, 30 deletions(-) diff --git a/src/sentry/utils/concurrent.py b/src/sentry/utils/concurrent.py index 033fc1c166b6..1cacad9a9306 100644 --- a/src/sentry/utils/concurrent.py +++ b/src/sentry/utils/concurrent.py @@ -19,27 +19,6 @@ logger = logging.getLogger(__name__) -def execute[T](function: Callable[..., T], daemon=True) -> Future[T]: - future: Future[T] = Future() - - def run(): - if not future.set_running_or_notify_cancel(): - return - - try: - result = function() - except Exception as e: - future.set_exception(e) - else: - future.set_result(result) - - t = threading.Thread(target=run) - t.daemon = daemon - t.start() - - return future - - @functools.total_ordering class PriorityTask[T](NamedTuple): priority: int diff --git a/tests/sentry/utils/test_concurrent.py b/tests/sentry/utils/test_concurrent.py index 4c249b074ff4..562383f58ae6 100644 --- a/tests/sentry/utils/test_concurrent.py +++ b/tests/sentry/utils/test_concurrent.py @@ -1,4 +1,3 @@ -import _thread import contextvars from concurrent.futures import CancelledError, Future from contextlib import contextmanager @@ -16,17 +15,9 @@ SynchronousExecutor, ThreadedExecutor, TimedFuture, - execute, ) -def test_execute() -> None: - assert execute(_thread.get_ident).result() != _thread.get_ident() - - with pytest.raises(Exception): - assert execute(mock.Mock(side_effect=Exception("Boom!"))).result() - - def test_future_set_callback_success() -> None: future_set = FutureSet([Future() for i in range(3)]) From b4f2897a34c2b59e8a1921501f08cffe47c03c2c Mon Sep 17 00:00:00 2001 From: Christinarlong <60594860+Christinarlong@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:46:19 -0700 Subject: [PATCH 17/35] fix(webhooks): Split out the metric alert action to be in a task (#112177) --- src/sentry/incidents/action_handlers.py | 11 +- .../integrations/services/integration/impl.py | 7 +- .../sentry_app_metric_alert_handler.py | 1 + .../rules/actions/notify_event_service.py | 46 +--- src/sentry/sentry_apps/metrics.py | 1 + src/sentry/sentry_apps/tasks/sentry_apps.py | 118 +++++++- src/sentry/sentry_apps/utils/webhooks.py | 29 +- .../test_sentry_app_metric_alert_handler.py | 1 + .../actions/test_notify_event_service.py | 104 ++++++- .../sentry_apps/tasks/test_sentry_apps.py | 260 ++++++++++++++++++ 10 files changed, 526 insertions(+), 52 deletions(-) diff --git a/src/sentry/incidents/action_handlers.py b/src/sentry/incidents/action_handlers.py index e6a8b00fc500..206406c7dbe7 100644 --- a/src/sentry/incidents/action_handlers.py +++ b/src/sentry/incidents/action_handlers.py @@ -462,22 +462,15 @@ def send_alert( incident_serialized_response = serialize(incident, serializer=IncidentSerializer()) - success = send_incident_alert_notification( + send_incident_alert_notification( notification_context=notification_context, alert_context=alert_context, metric_issue_context=metric_issue_context, incident_serialized_response=incident_serialized_response, organization=incident.organization, + project_id=project.id, notification_uuid=notification_uuid, ) - if success: - self.record_alert_sent_analytics( - organization_id=incident.organization.id, - project_id=project.id, - alert_id=incident.alert_rule.id, - external_id=action.sentry_app_id, - notification_uuid=notification_uuid, - ) def format_duration(minutes): diff --git a/src/sentry/integrations/services/integration/impl.py b/src/sentry/integrations/services/integration/impl.py index f33d6e750b05..1a628be719f7 100644 --- a/src/sentry/integrations/services/integration/impl.py +++ b/src/sentry/integrations/services/integration/impl.py @@ -45,7 +45,6 @@ serialize_integration_external_project, serialize_organization_integration, ) -from sentry.rules.actions.notify_event_service import find_alert_rule_action_ui_component from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.metrics import ( SentryAppEventType, @@ -54,7 +53,11 @@ ) from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation -from sentry.sentry_apps.utils.webhooks import MetricAlertActionType, SentryAppResourceType +from sentry.sentry_apps.utils.webhooks import ( + MetricAlertActionType, + SentryAppResourceType, + find_alert_rule_action_ui_component, +) from sentry.shared_integrations.exceptions import ApiError from sentry.utils import json from sentry.utils.sentry_apps import send_and_save_webhook_request diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py index 8037d57b8214..2c9b1f79c09d 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py @@ -54,5 +54,6 @@ def send_alert( metric_issue_context=metric_issue_context, incident_serialized_response=incident_serialized_response, organization=organization, + project_id=project.id, notification_uuid=notification_uuid, ) diff --git a/src/sentry/rules/actions/notify_event_service.py b/src/sentry/rules/actions/notify_event_service.py index 31fe09bf88bc..d794f172b669 100644 --- a/src/sentry/rules/actions/notify_event_service.py +++ b/src/sentry/rules/actions/notify_event_service.py @@ -13,15 +13,13 @@ NotificationContext, ) from sentry.integrations.metric_alerts import incident_attachment_info -from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization from sentry.plugins.base import plugins from sentry.rules.actions.base import EventAction from sentry.rules.actions.services import PluginService from sentry.rules.base import CallbackFuture -from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.services.app import RpcSentryAppService, app_service -from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app +from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app, send_metric_alert_webhook from sentry.services.eventstore.models import GroupEvent from sentry.utils import json, metrics from sentry.utils.forms import set_field_choices @@ -64,15 +62,15 @@ def send_incident_alert_notification( metric_issue_context: MetricIssueContext, incident_serialized_response: IncidentSerializerResponse, organization: Organization, + project_id: int, notification_uuid: str | None = None, -) -> bool: +) -> None: """ When a metric alert is triggered, send incident data to the SentryApp's webhook. :param action: The triggered `AlertRuleTriggerAction`. :param incident: The `Incident` for which to build a payload. - :param metric_value: The value of the metric that triggered this alert to - fire. - :return: + :param metric_value: The value of the metric that triggered this alert to fire. + :param project_id: project id will be used for analytics after sending the webhook. """ incident_attachment = build_incident_attachment( alert_context, @@ -85,40 +83,16 @@ def send_incident_alert_notification( if notification_context.sentry_app_id is None: raise ValueError("Sentry app ID is required") - success = integration_service.send_incident_alert_notification( - sentry_app_id=notification_context.sentry_app_id, + send_metric_alert_webhook.delay( + sentry_app_id=int(notification_context.sentry_app_id), new_status=metric_issue_context.new_status.value, incident_attachment_json=json.dumps(incident_attachment), organization_id=organization.id, - # TODO(iamrajjoshi): The rest of the params are unused - action_id=-1, - incident_id=-1, - metric_value=-1, - ) - return success - - -def find_alert_rule_action_ui_component(app_platform_event: AppPlatformEvent) -> bool: - """ - Loop through the triggers for the alert rule event. For each trigger, check - if an action is an alert rule UI Component - """ - triggers = ( - getattr(app_platform_event, "data", {}) - .get("metric_alert", {}) - .get("alert_rule", {}) - .get("triggers", []) + project_id=project_id, + alert_id=alert_context.action_identifier_id, + notification_uuid=notification_uuid, ) - actions = [ - action - for trigger in triggers - for action in trigger.get("actions", {}) - if (action.get("type") == "sentry_app" and action.get("settings") is not None) - ] - - return bool(len(actions)) - class NotifyEventServiceForm(forms.Form): service = forms.ChoiceField(choices=()) diff --git a/src/sentry/sentry_apps/metrics.py b/src/sentry/sentry_apps/metrics.py index 72eb5fea395f..be0004ad528b 100644 --- a/src/sentry/sentry_apps/metrics.py +++ b/src/sentry/sentry_apps/metrics.py @@ -60,6 +60,7 @@ class SentryAppWebhookFailureReason(StrEnum): EVENT_NOT_IN_SERVCEHOOK = "event_not_in_servicehook" MISSING_ISSUE_OCCURRENCE = "missing_issue_occurrence" MISSING_USER = "missing_user" + MULTIPLE_INSTALLATIONS = "multiple_installations" class SentryAppWebhookHaltReason(StrEnum): diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 3800cb619db7..518c6d0b5233 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -17,6 +17,7 @@ from sentry.analytics.events.alert_rule_ui_component_webhook_sent import ( AlertRuleUiComponentWebhookSentEvent, ) +from sentry.analytics.events.alert_sent import AlertSentEvent from sentry.analytics.events.comment_webhooks import ( CommentCreatedEvent, CommentDeletedEvent, @@ -36,6 +37,7 @@ from sentry.db.models.base import Model from sentry.exceptions import RestrictedIPAddress from sentry.hybridcloud.rpc.caching import cell_caching_service +from sentry.incidents.models.incident import INCIDENT_STATUS, IncidentStatus from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.activity import Activity from sentry.models.group import Group @@ -63,7 +65,12 @@ ) from sentry.sentry_apps.services.hook.service import hook_service from sentry.sentry_apps.utils.errors import SentryAppSentryError -from sentry.sentry_apps.utils.webhooks import IssueAlertActionType, SentryAppResourceType +from sentry.sentry_apps.utils.webhooks import ( + IssueAlertActionType, + MetricAlertActionType, + SentryAppResourceType, + find_alert_rule_action_ui_component, +) from sentry.services.eventstore.models import BaseEvent, Event, GroupEvent from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError from sentry.silo.base import SiloMode @@ -72,7 +79,7 @@ from sentry.types.rules import RuleFuture from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service -from sentry.utils import metrics +from sentry.utils import json, metrics from sentry.utils.function_cache import cache_func_for_models from sentry.utils.http import absolute_uri from sentry.utils.sentry_apps import send_and_save_webhook_request @@ -908,6 +915,113 @@ def regenerate_service_hooks_for_installation( ) +def _record_metric_alert_sent_analytics( + organization_id: int, + project_id: int, + alert_id: int, + sentry_app_id: int, + notification_uuid: str | None, +) -> None: + try: + analytics.record( + AlertSentEvent( + organization_id=organization_id, + project_id=project_id, + alert_id=alert_id, + alert_type="metric_alert", + provider="sentry_app", + external_id=str(sentry_app_id), + notification_uuid=notification_uuid, + ) + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + +def _record_metric_alert_ui_component_analytics( + organization_id: int, + sentry_app_id: int, + app_platform_event: AppPlatformEvent, +) -> None: + if not find_alert_rule_action_ui_component(app_platform_event): + return + try: + analytics.record( + AlertRuleUiComponentWebhookSentEvent( + organization_id=organization_id, + sentry_app_id=sentry_app_id, + event=f"{app_platform_event.resource}.{app_platform_event.action}", + ) + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + +@instrumented_task( + name="sentry.sentry_apps.tasks.sentry_apps.send_metric_alert_webhook", + namespace=sentryapp_tasks, + retry=Retry(times=3, delay=60 * 5), + silo_mode=SiloMode.CELL, +) +@retry_decorator +def send_metric_alert_webhook( + sentry_app_id: int, + new_status: int, + incident_attachment_json: str, + organization_id: int, + project_id: int, + alert_id: int, + notification_uuid: str | None = None, + **kwargs: Any, +) -> None: + try: + new_status_str = INCIDENT_STATUS[IncidentStatus(new_status)].lower() + event = SentryAppEventType( + f"{SentryAppResourceType.METRIC_ALERT}.{MetricAlertActionType(new_status_str)}" + ) + except ValueError as e: + sentry_sdk.capture_exception(e) + return + + with SentryAppInteractionEvent( + operation_type=SentryAppInteractionType.PREPARE_WEBHOOK, + event_type=event, + ).capture() as lifecycle: + sentry_app = app_service.get_sentry_app_by_id(id=sentry_app_id) + if sentry_app is None: + lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_SENTRY_APP) + return + + installations = app_service.get_many( + filter=dict( + organization_id=organization_id, + app_ids=[sentry_app.id], + status=SentryAppInstallationStatus.INSTALLED, + ) + ) + if not installations: + lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_INSTALLATION) + return + + if len(installations) > 1: + lifecycle.record_failure(SentryAppWebhookFailureReason.MULTIPLE_INSTALLATIONS) + return + + app_platform_event = AppPlatformEvent( + resource=SentryAppResourceType.METRIC_ALERT, + action=MetricAlertActionType(new_status_str), + install=installations[0], + data=json.loads(incident_attachment_json), + ) + + send_and_save_webhook_request(sentry_app, app_platform_event) + + _record_metric_alert_sent_analytics( + organization_id, project_id, alert_id, sentry_app.id, notification_uuid + ) + _record_metric_alert_ui_component_analytics(organization_id, sentry_app.id, app_platform_event) + + @instrumented_task( name="sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization", namespace=sentryapp_tasks, diff --git a/src/sentry/sentry_apps/utils/webhooks.py b/src/sentry/sentry_apps/utils/webhooks.py index 16f3dcb820eb..c4f112e229aa 100644 --- a/src/sentry/sentry_apps/utils/webhooks.py +++ b/src/sentry/sentry_apps/utils/webhooks.py @@ -1,5 +1,10 @@ +from __future__ import annotations + from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent class SentryAppActionType(StrEnum): @@ -106,3 +111,25 @@ def map_sentry_app_webhook_events( # per-event-type (issue.created, project.deleted, etc.). These are valid # resources a Sentry App may subscribe to. VALID_EVENT_RESOURCES = EVENT_EXPANSION.keys() + + +def find_alert_rule_action_ui_component( + app_platform_event: AppPlatformEvent[dict[str, Any]], +) -> bool: + """ + Returns True if the metric alert event contains a sentry app action with UI component settings. + Used to gate recording of AlertRuleUiComponentWebhookSentEvent analytics. + """ + triggers = ( + getattr(app_platform_event, "data", {}) + .get("metric_alert", {}) + .get("alert_rule", {}) + .get("triggers", []) + ) + actions = [ + action + for trigger in triggers + for action in trigger.get("actions", []) + if (action.get("type") == "sentry_app" and action.get("settings") is not None) + ] + return bool(len(actions)) diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py index 2e691a8334cb..92328e7e9328 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py @@ -85,6 +85,7 @@ def test_send_alert(self, mock_send_incident_alert_notification: mock.MagicMock) alert_context=alert_context, metric_issue_context=metric_issue_context, organization=self.detector.project.organization, + project_id=self.detector.project.id, notification_uuid=notification_uuid, incident_serialized_response=get_incident_serializer(self.open_period), ) diff --git a/tests/sentry/rules/actions/test_notify_event_service.py b/tests/sentry/rules/actions/test_notify_event_service.py index ed459cae6b46..864ff8666b0e 100644 --- a/tests/sentry/rules/actions/test_notify_event_service.py +++ b/tests/sentry/rules/actions/test_notify_event_service.py @@ -1,19 +1,31 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 +import pytest import responses from django.utils import timezone from requests.exceptions import HTTPError +from sentry.api.serializers import serialize from sentry.eventstream.types import EventStreamEventType from sentry.grouping.grouptype import ErrorGroupType +from sentry.incidents.endpoints.serializers.incident import IncidentSerializer +from sentry.incidents.models.incident import IncidentStatus +from sentry.incidents.typings.metric_detector import ( + AlertContext, + MetricIssueContext, + NotificationContext, +) from sentry.models.rule import Rule from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin -from sentry.rules.actions.notify_event_service import NotifyEventServiceAction +from sentry.rules.actions.notify_event_service import ( + NotifyEventServiceAction, + send_incident_alert_notification, +) from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app from sentry.silo.base import SiloMode from sentry.tasks.post_process import post_process_group -from sentry.testutils.cases import RuleTestCase +from sentry.testutils.cases import RuleTestCase, TestCase from sentry.testutils.helpers.eventprocessing import write_event_to_cache from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba @@ -248,3 +260,91 @@ def test_sentry_app_installed(self) -> None: results = rule.get_services() assert len(results) == 0 + + +class TestSendIncidentAlertNotification(TestCase): + def setUp(self) -> None: + self.project = self.create_project(organization=self.organization) + self.sentry_app = self.create_sentry_app(organization=self.organization) + self.alert_rule = self.create_alert_rule() + self.incident = self.create_incident(alert_rule=self.alert_rule) + self.alert_context = AlertContext.from_alert_rule_incident(self.alert_rule) + self.metric_issue_context = MetricIssueContext.from_legacy_models( + self.incident, IncidentStatus.CRITICAL, metric_value=100.0 + ) + self.incident_serialized_response = serialize( + self.incident, serializer=IncidentSerializer() + ) + self.notification_context = NotificationContext( + id=1, + sentry_app_id=self.sentry_app.id, + ) + self.notification_uuid = str(uuid4()) + + @patch("sentry.rules.actions.notify_event_service.send_metric_alert_webhook") + def test_dispatches_task_with_correct_kwargs(self, mock_task: MagicMock) -> None: + send_incident_alert_notification( + notification_context=self.notification_context, + alert_context=self.alert_context, + metric_issue_context=self.metric_issue_context, + incident_serialized_response=self.incident_serialized_response, + organization=self.organization, + project_id=self.project.id, + notification_uuid=self.notification_uuid, + ) + + mock_task.delay.assert_called_once() + call_kwargs = mock_task.delay.call_args.kwargs + assert call_kwargs["sentry_app_id"] == self.sentry_app.id + assert call_kwargs["new_status"] == IncidentStatus.CRITICAL.value + assert call_kwargs["organization_id"] == self.organization.id + assert call_kwargs["project_id"] == self.project.id + assert call_kwargs["alert_id"] == self.alert_rule.id + assert call_kwargs["notification_uuid"] == self.notification_uuid + + attachment = json.loads(call_kwargs["incident_attachment_json"]) + assert "metric_alert" in attachment + assert "description_title" in attachment + assert "description_text" in attachment + assert "web_url" in attachment + + @patch("sentry.rules.actions.notify_event_service.send_metric_alert_webhook") + def test_raises_when_sentry_app_id_is_none(self, mock_task: MagicMock) -> None: + notification_context_no_app = NotificationContext(id=1, sentry_app_id=None) + + with pytest.raises(ValueError, match="Sentry app ID is required"): + send_incident_alert_notification( + notification_context=notification_context_no_app, + alert_context=self.alert_context, + metric_issue_context=self.metric_issue_context, + incident_serialized_response=self.incident_serialized_response, + organization=self.organization, + project_id=self.project.id, + notification_uuid=self.notification_uuid, + ) + + mock_task.delay.assert_not_called() + + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") + def test_end_to_end_sends_webhook(self, safe_urlopen: MagicMock) -> None: + safe_urlopen.return_value = MagicMock(status_code=200, headers={}) + install = self.create_sentry_app_installation( + organization=self.organization, slug=self.sentry_app.slug + ) + + with self.tasks(): + send_incident_alert_notification( + notification_context=self.notification_context, + alert_context=self.alert_context, + metric_issue_context=self.metric_issue_context, + incident_serialized_response=self.incident_serialized_response, + organization=self.organization, + project_id=self.project.id, + notification_uuid=self.notification_uuid, + ) + + safe_urlopen.assert_called_once() + _, call_kwargs = safe_urlopen.call_args + body = json.loads(call_kwargs["data"]) + assert body["action"] == "critical" + assert body["installation"]["uuid"] == install.uuid diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py index 141cc6f7f250..04f980fe8c0c 100644 --- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py @@ -7,11 +7,16 @@ from requests import HTTPError from requests.exceptions import ChunkedEncodingError, ConnectionError, Timeout +from sentry.analytics.events.alert_rule_ui_component_webhook_sent import ( + AlertRuleUiComponentWebhookSentEvent, +) +from sentry.analytics.events.alert_sent import AlertSentEvent from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case from sentry.constants import SentryAppStatus from sentry.eventstream.types import EventStreamEventType from sentry.exceptions import RestrictedIPAddress +from sentry.incidents.models.incident import IncidentStatus from sentry.integrations.types import EventLifecycleOutcome from sentry.issues.ingest import save_issue_occurrence from sentry.models.activity import Activity @@ -26,6 +31,7 @@ process_resource_change_bound, regenerate_service_hooks_for_installation, send_alert_webhook_v2, + send_metric_alert_webhook, send_webhooks, workflow_notification, ) @@ -1849,3 +1855,257 @@ def test_regenerate_service_hook_for_installation_with_empty_app_events(self) -> with assume_test_silo_mode(SiloMode.CELL): hook.refresh_from_db() assert hook.events == [] + + +class TestSendMetricAlertWebhook(TestCase): + def setUp(self) -> None: + self.sentry_app = self.create_sentry_app(organization=self.organization) + self.install = self.create_sentry_app_installation( + organization=self.organization, slug=self.sentry_app.slug + ) + self.project = self.create_project(organization=self.organization) + self.alert_id = 42 + self.incident_attachment_json = json.dumps( + { + "metric_alert": { + "alert_rule": { + "triggers": [], + } + }, + "description_text": "Something went wrong", + "description_title": "Test Alert", + "web_url": "http://example.com/alert/1", + } + ) + + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_missing_sentry_app(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: + send_metric_alert_webhook( + sentry_app_id=9999, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + assert not safe_urlopen.called + assert_failure_metric( + mock_record=mock_record, + error_msg=SentryAppWebhookFailureReason.MISSING_SENTRY_APP, + ) + # PREPARE_WEBHOOK (failure) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1 + ) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 + ) + + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_missing_installation(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: + other_org = self.create_organization() + uninstalled_app = self.create_sentry_app(organization=other_org) + + send_metric_alert_webhook( + sentry_app_id=uninstalled_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + assert not safe_urlopen.called + assert_failure_metric( + mock_record=mock_record, + error_msg=SentryAppWebhookFailureReason.MISSING_INSTALLATION, + ) + # APP_CREATE (success) -> PREPARE_WEBHOOK (failure) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2 + ) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 + ) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_successful_send_critical_status( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + notification_uuid = "test-notification-uuid" + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + notification_uuid=notification_uuid, + ) + + assert safe_urlopen.called + ((args, kwargs),) = safe_urlopen.call_args_list + data = json.loads(kwargs["data"]) + assert data["action"] == "critical" + assert data["installation"]["uuid"] == self.install.uuid + assert data["data"]["metric_alert"]["alert_rule"]["triggers"] == [] + + buffer = SentryAppWebhookRequestsBuffer(self.sentry_app) + requests = buffer.get_requests() + assert len(requests) == 1 + assert requests[0]["response_code"] == 200 + assert requests[0]["event_type"] == "metric_alert.critical" + + assert_success_metric(mock_record=mock_record) + # PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2 + ) + assert_count_of_metric( + mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2 + ) + + mock_analytics_record.assert_any_call( + AlertSentEvent( + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + alert_type="metric_alert", + provider="sentry_app", + external_id=str(self.sentry_app.id), + notification_uuid=notification_uuid, + ) + ) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_successful_send_closed_status( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CLOSED.value, + incident_attachment_json=self.incident_attachment_json, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + assert safe_urlopen.called + ((args, kwargs),) = safe_urlopen.call_args_list + data = json.loads(kwargs["data"]) + assert data["action"] == "resolved" + + buffer = SentryAppWebhookRequestsBuffer(self.sentry_app) + requests = buffer.get_requests() + assert len(requests) == 1 + assert requests[0]["event_type"] == "metric_alert.resolved" + + assert_success_metric(mock_record=mock_record) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_ui_component_analytics_recorded_when_sentry_app_action_with_settings( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + attachment_with_ui_component = json.dumps( + { + "metric_alert": { + "alert_rule": { + "triggers": [ + { + "actions": [ + { + "type": "sentry_app", + "settings": {"key": "value"}, + } + ] + } + ] + } + }, + "description_text": "Something happened", + "description_title": "Test Alert", + "web_url": "http://example.com/alert/1", + } + ) + + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=attachment_with_ui_component, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + mock_analytics_record.assert_any_call( + AlertRuleUiComponentWebhookSentEvent( + organization_id=self.organization.id, + sentry_app_id=self.sentry_app.id, + event="metric_alert.critical", + ) + ) + + @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") + @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_ui_component_analytics_not_recorded_without_settings( + self, + mock_record: MagicMock, + safe_urlopen: MagicMock, + mock_analytics_record: MagicMock, + ) -> None: + attachment_without_settings = json.dumps( + { + "metric_alert": { + "alert_rule": { + "triggers": [ + { + "actions": [ + { + "type": "sentry_app", + # no "settings" key + } + ] + } + ] + } + }, + "description_text": "Something happened", + "description_title": "Test Alert", + "web_url": "http://example.com/alert/1", + } + ) + + send_metric_alert_webhook( + sentry_app_id=self.sentry_app.id, + new_status=IncidentStatus.CRITICAL.value, + incident_attachment_json=attachment_without_settings, + organization_id=self.organization.id, + project_id=self.project.id, + alert_id=self.alert_id, + ) + + ui_component_calls = [ + call + for call in mock_analytics_record.call_args_list + if isinstance(call.args[0], AlertRuleUiComponentWebhookSentEvent) + ] + assert len(ui_component_calls) == 0 From f2148f4fc7ebc726cbe327acbeb73f7961577663 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Fri, 3 Apr 2026 11:57:06 -0700 Subject: [PATCH 18/35] feat(occurrences on eap): Implement tagstore EAP query for group list tag value (#111963) Implements double reads of occurrences from EAP for `get_group_list_tag_value` & `get_generic_group_list_tag_value` in `src/sentry/tagstore/snuba/backend.py`. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- src/sentry/tagstore/snuba/backend.py | 186 +++++++++++++++++- tests/snuba/tagstore/test_tagstore_backend.py | 150 ++++++++++++++ 2 files changed, 333 insertions(+), 3 deletions(-) diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 64e528122392..1275daf6e54d 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -164,6 +164,32 @@ def _reasonable_release_tags_match(control: set[TagValue], experimental: set[Tag ) +def _reasonable_group_list_tag_value_match( + control: dict[int, GroupTagValue], + experimental: dict[int, GroupTagValue], +) -> bool: + if not set(experimental.keys()).issubset(set(control.keys())): + return False + for group_id in experimental: + ctrl = control[group_id] + exp = experimental[group_id] + if exp.times_seen > ctrl.times_seen: + return False + if ( + exp.first_seen is not None + and ctrl.first_seen is not None + and exp.first_seen < ctrl.first_seen + ): + return False + if ( + exp.last_seen is not None + and ctrl.last_seen is not None + and exp.last_seen > ctrl.last_seen + ): + return False + return True + + class _OptimizeKwargs(TypedDict, total=False): turbo: bool sample: int @@ -825,7 +851,7 @@ def get_group_list_tag_value( value, tenant_ids=None, ): - return self.__get_group_list_tag_value( + snuba_result = self.__get_group_list_tag_value( project_ids, group_id_list, environment_ids, @@ -834,9 +860,38 @@ def get_group_list_tag_value( Dataset.Events, [DEFAULT_TYPE_CONDITION], [], - "tagstore.get_group_list_tag_value", + Referrer.TAGSTORE_GET_GROUP_LIST_TAG_VALUE.value, tenant_ids=tenant_ids, ) + result = snuba_result + + callsite = "SnubaTagStorage::get_group_list_tag_value" + if EAPOccurrencesComparator.should_check_experiment(callsite): + eap_result = self._eap_get_group_list_tag_value( + project_ids, + group_id_list, + environment_ids, + key, + value, + referrer=Referrer.TAGSTORE_GET_GROUP_LIST_TAG_VALUE.value, + occurrence_category=OccurrenceCategory.ERROR, + ) + result = EAPOccurrencesComparator.check_and_choose( + control_data=snuba_result, + experimental_data=eap_result, + callsite=callsite, + is_experimental_data_a_null_result=len(eap_result) == 0, + reasonable_match_comparator=_reasonable_group_list_tag_value_match, + debug_context={ + "project_ids": list(project_ids), + "group_ids": list(group_id_list), + "environment_ids": list(environment_ids) if environment_ids else None, + "key": key, + "value": value, + }, + ) + + return result def get_generic_group_list_tag_value( self, @@ -890,12 +945,137 @@ def get_generic_group_list_tag_value( result_snql["data"], ["group_id"], ["times_seen", "first_seen", "last_seen"] ) - return { + snuba_result = { group_id: GroupTagValue( group_id=group_id, key=key, value=value, **fix_tag_value_data(data) ) for group_id, data in nested_groups.items() } + result = snuba_result + + callsite = "SnubaTagStorage::get_generic_group_list_tag_value" + if EAPOccurrencesComparator.should_check_experiment(callsite): + eap_result = self._eap_get_group_list_tag_value( + project_ids, + group_id_list, + environment_ids, + key, + value, + referrer=Referrer.TAGSTORE_GET_GENERIC_GROUP_LIST_TAG_VALUE.value, + occurrence_category=OccurrenceCategory.ISSUE_PLATFORM, + ) + result = EAPOccurrencesComparator.check_and_choose( + control_data=snuba_result, + experimental_data=eap_result, + callsite=callsite, + is_experimental_data_a_null_result=len(eap_result) == 0, + reasonable_match_comparator=_reasonable_group_list_tag_value_match, + debug_context={ + "project_ids": list(project_ids), + "group_ids": list(group_id_list), + "environment_ids": list(environment_ids) if environment_ids else None, + "key": key, + "value": value, + }, + ) + + return result + + def _eap_get_group_list_tag_value( + self, + project_ids: Sequence[int], + group_id_list: Sequence[int], + environment_ids: Sequence[int] | None, + key: str, + value: str, + referrer: str, + occurrence_category: OccurrenceCategory, + ) -> dict[int, GroupTagValue]: + organization_id = get_organization_id_from_project_ids(project_ids) + + now = datetime.now(tz=timezone.utc) + resolved_start = now - timedelta(days=90) + resolved_end = now + + try: + organization = Organization.objects.get_from_cache(id=organization_id) + except Organization.DoesNotExist: + return {} + + projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) + if not projects: + return {} + + environments = ( + list(Environment.objects.filter(id__in=environment_ids)) if environment_ids else [] + ) + + group_id_filter = f"group_id:[{','.join(str(gid) for gid in group_id_list)}]" + tag_filter = build_escaped_term_filter(key, [str(value)]) + query_string = f"{group_id_filter} {tag_filter}" + + snuba_params = SnubaParams( + start=resolved_start, + end=resolved_end, + organization=organization, + projects=projects, + environments=environments, + ) + + try: + result = Occurrences.run_table_query( + params=snuba_params, + query_string=query_string, + selected_columns=["group_id", "count()", "min(timestamp)", "last_seen()"], + orderby=None, + offset=0, + limit=len(group_id_list), + referrer=referrer, + config=SearchResolverConfig(), + occurrence_category=occurrence_category, + ) + + output: dict[int, GroupTagValue] = {} + for row in result.get("data", []): + group_id = row.get("group_id") + if group_id is None: + continue + first_seen_raw = row.get("min(timestamp)") + last_seen_raw = row.get("last_seen()") + output[int(group_id)] = GroupTagValue( + group_id=int(group_id), + key=key, + value=value, + times_seen=int(row.get("count()", 0)), + first_seen=( + parse_datetime(datetime_processor(first_seen_raw)).replace( + tzinfo=timezone.utc + ) + if first_seen_raw is not None + else None + ), + last_seen=( + parse_datetime(datetime_processor(last_seen_raw)).replace( + tzinfo=timezone.utc + ) + if last_seen_raw is not None + else None + ), + ) + return output + except Exception: + logger.exception( + "EAP get_group_list_tag_value query failed", + extra={ + "organization_id": organization_id, + "project_ids": list(project_ids), + "group_ids": list(group_id_list), + "key": key, + "value": value, + "occurrence_category": occurrence_category.value, + }, + ) + return {} def apply_group_filters(self, group: Group | None, filters: MutableMapping[str, Sequence[Any]]): dataset = Dataset.Events diff --git a/tests/snuba/tagstore/test_tagstore_backend.py b/tests/snuba/tagstore/test_tagstore_backend.py index e7262a5b80fa..c7096da486a0 100644 --- a/tests/snuba/tagstore/test_tagstore_backend.py +++ b/tests/snuba/tagstore/test_tagstore_backend.py @@ -1911,3 +1911,153 @@ def test_eap_and_snuba_release_tags_match(self) -> None: assert snuba_by_value["1.0"].last_seen == eap_by_value["1.0"].last_seen assert snuba_by_value["2.0"].first_seen == eap_by_value["2.0"].first_seen assert snuba_by_value["2.0"].last_seen == eap_by_value["2.0"].last_seen + + @freeze_time(FROZEN_TIME) + def test_eap_and_snuba_group_list_tag_value_match_multiple_groups(self) -> None: + ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp() + env = self.create_environment(project=self.project, name="production") + + # Group A: 2 events in "production" + self.store_events_to_snuba_and_eap( + "group-a", count=1, timestamp=ts, extra_event_data={"environment": env.name} + ) + events_a = self.store_events_to_snuba_and_eap( + "group-a", count=1, timestamp=ts, extra_event_data={"environment": env.name} + ) + group_a = events_a[0].group + assert group_a is not None + + # Group B: 1 event in "production" + events_b = self.store_events_to_snuba_and_eap( + "group-b", count=1, timestamp=ts, extra_event_data={"environment": env.name} + ) + group_b = events_b[0].group + assert group_b is not None + + group_ids = [group_a.id, group_b.id] + + snuba_result = self.ts.get_group_list_tag_value( + [self.project.id], + group_ids, + [env.id], + "environment", + env.name, + tenant_ids={"referrer": "r", "organization_id": self.project.organization_id}, + ) + eap_result = self.ts._eap_get_group_list_tag_value( + [self.project.id], + group_ids, + [env.id], + "environment", + env.name, + referrer="tagstore.get_group_list_tag_value", + occurrence_category=OccurrenceCategory.ERROR, + ) + + assert set(snuba_result.keys()) == {group_a.id, group_b.id} + assert set(eap_result.keys()) == set(snuba_result.keys()) + assert snuba_result[group_a.id].times_seen == eap_result[group_a.id].times_seen == 2 + assert snuba_result[group_b.id].times_seen == eap_result[group_b.id].times_seen == 1 + assert snuba_result[group_a.id].first_seen == eap_result[group_a.id].first_seen + assert snuba_result[group_a.id].last_seen == eap_result[group_a.id].last_seen + assert snuba_result[group_b.id].first_seen == eap_result[group_b.id].first_seen + assert snuba_result[group_b.id].last_seen == eap_result[group_b.id].last_seen + + @freeze_time(FROZEN_TIME) + def test_eap_and_snuba_group_list_tag_value_with_environment_filter(self) -> None: + ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp() + env_prod = self.create_environment(project=self.project, name="production") + env_staging = self.create_environment(project=self.project, name="staging") + + # 2 events in "production", 1 event in "staging" + self.store_events_to_snuba_and_eap( + "group-a", count=1, timestamp=ts, extra_event_data={"environment": env_prod.name} + ) + self.store_events_to_snuba_and_eap( + "group-a", count=1, timestamp=ts, extra_event_data={"environment": env_prod.name} + ) + events = self.store_events_to_snuba_and_eap( + "group-a", count=1, timestamp=ts, extra_event_data={"environment": env_staging.name} + ) + group_a = events[0].group + assert group_a is not None + + # Filter by production only + snuba_result = self.ts.get_group_list_tag_value( + [self.project.id], + [group_a.id], + [env_prod.id], + "environment", + env_prod.name, + tenant_ids={"referrer": "r", "organization_id": self.project.organization_id}, + ) + eap_result = self.ts._eap_get_group_list_tag_value( + [self.project.id], + [group_a.id], + [env_prod.id], + "environment", + env_prod.name, + referrer="tagstore.get_group_list_tag_value", + occurrence_category=OccurrenceCategory.ERROR, + ) + + assert group_a.id in snuba_result + assert group_a.id in eap_result + assert snuba_result[group_a.id].times_seen == eap_result[group_a.id].times_seen == 2 + assert snuba_result[group_a.id].first_seen == eap_result[group_a.id].first_seen + assert snuba_result[group_a.id].last_seen == eap_result[group_a.id].last_seen + + @freeze_time(FROZEN_TIME) + def test_eap_group_list_tag_value_issue_platform(self) -> None: + ts = self.FROZEN_TIME - timedelta(minutes=5) + env = self.create_environment(project=self.project, name="production") + + group = self.create_group(project=self.project) + + eap_item_1 = self.create_eap_occurrence( + group_id=group.id, + timestamp=ts, + environment=env.name, + issue_occurrence_id=uuid4().hex, + ) + eap_item_2 = self.create_eap_occurrence( + group_id=group.id, + timestamp=ts + timedelta(minutes=1), + environment=env.name, + issue_occurrence_id=uuid4().hex, + ) + self.store_eap_items([eap_item_1, eap_item_2]) + + eap_result = self.ts._eap_get_group_list_tag_value( + [self.project.id], + [group.id], + [env.id], + "environment", + env.name, + referrer="tagstore.get_generic_group_list_tag_value", + occurrence_category=OccurrenceCategory.ISSUE_PLATFORM, + ) + + assert group.id in eap_result + assert eap_result[group.id].times_seen == 2 + assert eap_result[group.id].key == "environment" + assert eap_result[group.id].value == env.name + assert eap_result[group.id].first_seen is not None + assert eap_result[group.id].last_seen is not None + assert eap_result[group.id].first_seen <= eap_result[group.id].last_seen + + @freeze_time(FROZEN_TIME) + def test_eap_group_list_tag_value_empty_result(self) -> None: + group = self.create_group(project=self.project) + + eap_result = self.ts._eap_get_group_list_tag_value( + [self.project.id], + [group.id], + None, + "environment", + "nonexistent", + referrer="tagstore.get_group_list_tag_value", + occurrence_category=OccurrenceCategory.ERROR, + ) + + assert eap_result == {} From ca7fcd448bd6b20767752365bcc11406aabfa0c9 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Fri, 3 Apr 2026 19:03:33 +0000 Subject: [PATCH 19/35] Revert "feat(github): Handle installation_repositories webhook (#111864)" This reverts commit a8a8009b4978fb947de92944f52736acd55f06bc. Co-authored-by: wedamija <6288560+wedamija@users.noreply.github.com> --- src/sentry/features/temporary.py | 1 - .../integrations/bitbucket/repository.py | 3 +- .../bitbucket_server/repository.py | 3 +- src/sentry/integrations/github/repository.py | 2 +- .../integrations/github/tasks/__init__.py | 2 - .../github/tasks/link_all_repos.py | 8 +- .../tasks/sync_repos_on_install_change.py | 136 -------- src/sentry/integrations/github/webhook.py | 53 ---- .../integrations/github/webhook_types.py | 23 +- .../github_enterprise/repository.py | 3 +- src/sentry/integrations/gitlab/repository.py | 3 +- .../integrations/perforce/repository.py | 2 +- .../integrations/services/repository/impl.py | 17 - .../services/repository/service.py | 15 - .../source_code_management/metrics.py | 1 - src/sentry/integrations/utils/metrics.py | 1 - src/sentry/integrations/vsts/repository.py | 2 +- .../middleware/integrations/parsers/github.py | 5 +- .../integrations/parsers/github_enterprise.py | 10 - .../providers/integration_repository.py | 19 +- .../test_sync_repos_on_install_change.py | 205 ------------ .../{test_webhook.py => test_webhooks.py} | 298 +----------------- .../services/repository/test_impl.py | 162 ---------- .../integrations/parsers/test_github.py | 46 --- 24 files changed, 18 insertions(+), 1002 deletions(-) delete mode 100644 src/sentry/integrations/github/tasks/sync_repos_on_install_change.py delete mode 100644 tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py rename tests/sentry/integrations/github/{test_webhook.py => test_webhooks.py} (82%) delete mode 100644 tests/sentry/integrations/services/repository/test_impl.py diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index fe3942519d5d..00f103ab2aeb 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -137,7 +137,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - manager.add("organizations:github-repo-auto-sync", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 17610a849ad3..78aa2a539075 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,4 +1,3 @@ -from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -48,7 +47,7 @@ def get_webhook_secret(self, organization): return secret def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 528e2bd6bd94..6b3bab8c6c46 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -1,4 +1,3 @@ -from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -36,7 +35,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index 766c1e03a8a4..b901fc89a839 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -52,7 +52,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: return { "name": data["identifier"], diff --git a/src/sentry/integrations/github/tasks/__init__.py b/src/sentry/integrations/github/tasks/__init__.py index cc31059167a9..a635eebb4b9a 100644 --- a/src/sentry/integrations/github/tasks/__init__.py +++ b/src/sentry/integrations/github/tasks/__init__.py @@ -2,12 +2,10 @@ from .codecov_account_unlink import codecov_account_unlink from .link_all_repos import link_all_repos from .pr_comment import github_comment_workflow -from .sync_repos_on_install_change import sync_repos_on_install_change __all__ = ( "codecov_account_link", "codecov_account_unlink", "github_comment_workflow", "link_all_repos", - "sync_repos_on_install_change", ) diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index 046c0fe46623..ade3e8ef83a7 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -1,5 +1,4 @@ import logging -from collections.abc import Mapping from typing import Any from taskbroker_client.retry import Retry @@ -14,7 +13,6 @@ from sentry.organizations.services.organization import organization_service from sentry.plugins.providers.integration_repository import ( RepoExistsError, - RepositoryInputConfig, get_integration_repository_provider, ) from sentry.shared_integrations.exceptions import ApiError @@ -25,9 +23,9 @@ logger = logging.getLogger(__name__) -def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryInputConfig: +def get_repo_config(repo, integration_id): return { - "external_id": str(repo["id"]), + "external_id": repo["id"], "integration_id": integration_id, "identifier": repo["full_name"], } @@ -79,7 +77,7 @@ def link_all_repos( integration_repo_provider = get_integration_repository_provider(integration) - repo_configs: list[RepositoryInputConfig] = [] + repo_configs: list[dict[str, Any]] = [] missing_repos = [] for repo in repositories: try: diff --git a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py deleted file mode 100644 index c3ab3b701551..000000000000 --- a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -from typing import Literal - -from taskbroker_client.retry import Retry - -from sentry import features -from sentry.constants import ObjectStatus -from sentry.integrations.github.webhook_types import GitHubInstallationRepo -from sentry.integrations.services.integration import integration_service -from sentry.integrations.services.integration.model import RpcIntegration -from sentry.integrations.services.repository.service import repository_service -from sentry.integrations.source_code_management.metrics import ( - SCMIntegrationInteractionEvent, - SCMIntegrationInteractionType, -) -from sentry.organizations.services.organization import organization_service -from sentry.organizations.services.organization.model import RpcOrganization -from sentry.plugins.providers.integration_repository import ( - RepoExistsError, - RepositoryInputConfig, - get_integration_repository_provider, -) -from sentry.silo.base import SiloMode -from sentry.tasks.base import instrumented_task, retry -from sentry.taskworker.namespaces import integrations_control_tasks - -from .link_all_repos import get_repo_config - -logger = logging.getLogger(__name__) - - -@instrumented_task( - name="sentry.integrations.github.tasks.sync_repos_on_install_change", - namespace=integrations_control_tasks, - retry=Retry(times=3, delay=120), - processing_deadline_duration=120, - silo_mode=SiloMode.CONTROL, -) -@retry(exclude=(RepoExistsError, KeyError)) -def sync_repos_on_install_change( - integration_id: int, - action: str, - repos_added: list[GitHubInstallationRepo], - repos_removed: list[GitHubInstallationRepo], - repository_selection: Literal["all", "selected"], -) -> None: - """ - Handle GitHub installation_repositories webhook events. - - Creates Repository records for newly accessible repos and disables - records for repos that are no longer accessible, across all orgs - linked to the integration. - """ - result = integration_service.organization_contexts(integration_id=integration_id) - integration = result.integration - org_integrations = result.organization_integrations - - if integration is None or integration.status != ObjectStatus.ACTIVE: - logger.info( - "sync_repos_on_install_change.missing_or_inactive_integration", - extra={"integration_id": integration_id}, - ) - return - - if not org_integrations: - logger.info( - "sync_repos_on_install_change.no_org_integrations", - extra={"integration_id": integration_id}, - ) - return - - provider = f"integrations:{integration.provider}" - - for oi in org_integrations: - organization_id = oi.organization_id - rpc_org = organization_service.get(id=organization_id) - - if rpc_org is None: - logger.info( - "sync_repos_on_install_change.missing_organization", - extra={"organization_id": organization_id}, - ) - continue - - if not features.has("organizations:github-repo-auto-sync", rpc_org): - continue - - with SCMIntegrationInteractionEvent( - interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE, - integration_id=integration_id, - organization_id=organization_id, - provider_key=integration.provider, - ).capture(): - _sync_repos_for_org( - integration=integration, - rpc_org=rpc_org, - provider=provider, - repos_added=repos_added, - repos_removed=repos_removed, - ) - - -def _sync_repos_for_org( - *, - integration: RpcIntegration, - rpc_org: RpcOrganization, - provider: str, - repos_added: list[GitHubInstallationRepo], - repos_removed: list[GitHubInstallationRepo], -) -> None: - if repos_added: - integration_repo_provider = get_integration_repository_provider(integration) - repo_configs: list[RepositoryInputConfig] = [] - for repo in repos_added: - try: - repo_configs.append(get_repo_config(repo, integration.id)) - except KeyError: - logger.exception("Failed to translate repository config") - continue - - if repo_configs: - try: - integration_repo_provider.create_repositories( - configs=repo_configs, organization=rpc_org - ) - except RepoExistsError: - pass - - if repos_removed: - external_ids = [str(repo["id"]) for repo in repos_removed] - repository_service.disable_repositories_by_external_ids( - organization_id=rpc_org.id, - integration_id=integration.id, - provider=provider, - external_ids=external_ids, - ) diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index 28e87abd1ef5..b5b86fea0c0a 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -30,7 +30,6 @@ from sentry.integrations.github.webhook_types import ( GITHUB_WEBHOOK_TYPE_HEADER_KEY, GithubWebhookType, - InstallationRepositoriesEvent, ) from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import ( @@ -419,57 +418,6 @@ def _handle_organization_deletion( ) -class InstallationRepositoriesEventWebhook(GitHubWebhook): - """ - Handles installation_repositories events when repos are added to or - removed from the GitHub App installation. Runs in control silo. - - https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories - """ - - EVENT_TYPE = IntegrationWebhookEventType.INSTALLATION_REPOSITORIES - - def __call__( # type: ignore[override] - self, event: InstallationRepositoriesEvent, host: str | None = None, **kwargs: Any - ) -> None: - external_id = get_github_external_id(event=event, host=host) - if external_id is None: - return - - result = integration_service.organization_contexts( - provider=self.provider, - external_id=external_id, - ) - integration = result.integration - - if integration is None: - logger.warning( - "github.installation_repositories.missing_integration", - extra={"external_id": str(external_id)}, - ) - return - - action = event["action"] - repos_added = event["repositories_added"] - repos_removed = event["repositories_removed"] - repository_selection = event["repository_selection"] - - if not repos_added and not repos_removed: - return - - from .tasks.sync_repos_on_install_change import sync_repos_on_install_change - - sync_repos_on_install_change.apply_async( - kwargs={ - "integration_id": integration.id, - "action": action, - "repos_added": repos_added, - "repos_removed": repos_removed, - "repository_selection": repository_selection, - } - ) - - class PushEventWebhook(GitHubWebhook): """https://developer.github.com/v3/activity/events/types/#pushevent""" @@ -1010,7 +958,6 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint): _handlers: dict[GithubWebhookType, type[GitHubWebhook]] = { GithubWebhookType.CHECK_RUN: CheckRunEventWebhook, GithubWebhookType.INSTALLATION: InstallationEventWebhook, - GithubWebhookType.INSTALLATION_REPOSITORIES: InstallationRepositoriesEventWebhook, GithubWebhookType.ISSUE: IssuesEventWebhook, GithubWebhookType.ISSUE_COMMENT: IssueCommentEventWebhook, GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook, diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index eaad179b8ae1..0ad1061471a0 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,7 +1,6 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Literal, TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -23,25 +22,7 @@ class GithubWebhookType(StrEnum): # Event type strings (X-GitHub-Event header values) that the cell webhook endpoint processes. -# INSTALLATION and INSTALLATION_REPOSITORIES are handled in control only. -_CONTROL_ONLY_EVENTS = frozenset( - {GithubWebhookType.INSTALLATION, GithubWebhookType.INSTALLATION_REPOSITORIES} -) +# INSTALLATION is handled in control only. CELL_PROCESSED_GITHUB_EVENTS = frozenset( - t.value for t in GithubWebhookType if t not in _CONTROL_ONLY_EVENTS + t.value for t in GithubWebhookType if t != GithubWebhookType.INSTALLATION ) - - -class GitHubInstallationRepo(TypedDict): - id: int - full_name: str - private: bool - - -class InstallationRepositoriesEvent(TypedDict): - action: Literal["added", "removed"] - installation: dict[str, Any] - repositories_added: list[GitHubInstallationRepo] - repositories_removed: list[GitHubInstallationRepo] - repository_selection: Literal["all", "selected"] - sender: dict[str, Any] diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 2835befdf391..5f256206ffd4 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,4 +1,3 @@ -from collections.abc import Mapping from typing import Any from sentry.integrations.github.repository import GitHubRepositoryProvider @@ -30,7 +29,7 @@ def _validate_repo(self, client, installation, repo): return repo_data def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: integration = integration_service.get_integration( integration_id=data["integration_id"], provider=self.repo_provider diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index d2285d73b195..1b889c641c5c 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,4 +1,3 @@ -from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -36,7 +35,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 52d84dd91c13..0adea7741301 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -102,7 +102,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: """ Build repository configuration for database storage. diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index d1cf84cdcb44..39238a71778c 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -134,23 +134,6 @@ def disable_repositories_for_integration( provider=provider, ).update(status=ObjectStatus.DISABLED) - def disable_repositories_by_external_ids( - self, - *, - organization_id: int, - integration_id: int, - provider: str, - external_ids: list[str], - ) -> None: - with transaction.atomic(router.db_for_write(Repository)): - Repository.objects.filter( - organization_id=organization_id, - integration_id=integration_id, - provider=provider, - external_id__in=external_ids, - status=ObjectStatus.ACTIVE, - ).update(status=ObjectStatus.DISABLED) - def disassociate_organization_integration( self, *, diff --git a/src/sentry/integrations/services/repository/service.py b/src/sentry/integrations/services/repository/service.py index 51cb81c98ba8..a10d8c42852a 100644 --- a/src/sentry/integrations/services/repository/service.py +++ b/src/sentry/integrations/services/repository/service.py @@ -85,21 +85,6 @@ def disable_repositories_for_integration( Code owners and code mappings will not be changed. """ - @cell_rpc_method(resolve=ByOrganizationId()) - @abstractmethod - def disable_repositories_by_external_ids( - self, - *, - organization_id: int, - integration_id: int, - provider: str, - external_ids: list[str], - ) -> None: - """ - Disables specific repositories by external_id for a given integration. - Only active repositories are affected. Code mappings and commits are preserved. - """ - @cell_rpc_method(resolve=ByOrganizationId()) @abstractmethod def disassociate_organization_integration( diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index a6612f568092..6cc035d5bcab 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -41,7 +41,6 @@ class SCMIntegrationInteractionType(StrEnum): # Tasks LINK_ALL_REPOS = "link_all_repos" - SYNC_REPOS_ON_INSTALL_CHANGE = "sync_repos_on_install_change" # GitHub only DERIVE_CODEMAPPINGS = "derive_codemappings" diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index a341f8c31833..6d0f8ea33ea2 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -448,7 +448,6 @@ class IntegrationWebhookEventType(StrEnum): # This represents a webhook event for an inbound sync operation, such as syncing external resources or data into Sentry. INBOUND_SYNC = "inbound_sync" INSTALLATION = "installation" - INSTALLATION_REPOSITORIES = "installation_repositories" ISSUE_COMMENT = "issue_comment" MERGE_REQUEST = "pull_request" MERGE_REQUEST_REVIEW = "pull_request_review" diff --git a/src/sentry/integrations/vsts/repository.py b/src/sentry/integrations/vsts/repository.py index ac0157719601..f9a9b74007ac 100644 --- a/src/sentry/integrations/vsts/repository.py +++ b/src/sentry/integrations/vsts/repository.py @@ -47,7 +47,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: return { "name": data["name"], diff --git a/src/sentry/middleware/integrations/parsers/github.py b/src/sentry/middleware/integrations/parsers/github.py index f7c591953467..c6ec3f3c5bb1 100644 --- a/src/sentry/middleware/integrations/parsers/github.py +++ b/src/sentry/middleware/integrations/parsers/github.py @@ -77,10 +77,7 @@ def get_mailbox_identifier( def should_route_to_control_silo( self, parsed_event: Mapping[str, Any], request: HttpRequest ) -> bool: - return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) in ( - GithubWebhookType.INSTALLATION, - GithubWebhookType.INSTALLATION_REPOSITORIES, - ) + return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION @control_silo_function def get_integration_from_request(self) -> Integration | None: diff --git a/src/sentry/middleware/integrations/parsers/github_enterprise.py b/src/sentry/middleware/integrations/parsers/github_enterprise.py index 02edd104dfdb..3f7cbdce60d0 100644 --- a/src/sentry/middleware/integrations/parsers/github_enterprise.py +++ b/src/sentry/middleware/integrations/parsers/github_enterprise.py @@ -4,11 +4,8 @@ from collections.abc import Mapping from typing import Any -from django.http import HttpRequest - from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier from sentry.integrations.github.webhook import get_github_external_id -from sentry.integrations.github.webhook_types import GITHUB_WEBHOOK_TYPE_HEADER, GithubWebhookType from sentry.integrations.github_enterprise.webhook import GitHubEnterpriseWebhookEndpoint, get_host from sentry.integrations.types import IntegrationProviderSlug from sentry.middleware.integrations.parsers.github import GithubRequestParser @@ -21,13 +18,6 @@ class GithubEnterpriseRequestParser(GithubRequestParser): webhook_identifier = WebhookProviderIdentifier.GITHUB_ENTERPRISE webhook_endpoint = GitHubEnterpriseWebhookEndpoint - def should_route_to_control_silo( - self, parsed_event: Mapping[str, Any], request: HttpRequest - ) -> bool: - # GHE only routes installation events to control silo. - # installation_repositories is not yet supported for GHE. - return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION - def _get_external_id(self, event: Mapping[str, Any]) -> str | None: host = get_host(request=self.request) if not host: diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index e238eccd3124..9be762ce7668 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -1,9 +1,8 @@ from __future__ import annotations import logging -from collections.abc import Mapping from datetime import timezone -from typing import Any, ClassVar, NotRequired, TypedDict +from typing import Any, ClassVar, TypedDict from dateutil.parser import parse as parse_date from rest_framework import status @@ -28,16 +27,6 @@ from sentry.utils import metrics -class RepositoryInputConfig(TypedDict): - """Input config passed to create_repositories / build_repository_config. - Providers may include additional keys beyond these.""" - - external_id: str - integration_id: int - identifier: str - installation: NotRequired[str] - - class RepositoryConfig(TypedDict): name: str external_id: str @@ -118,7 +107,7 @@ def get_installation( def create_repository( self, - repo_config: Mapping[str, Any], + repo_config: dict[str, Any], organization: RpcOrganization, ): result = self.build_repository_config(organization=organization, data=repo_config) @@ -238,7 +227,7 @@ def _update_repositories( def create_repositories( self, - configs: list[RepositoryInputConfig], + configs: list[dict[str, Any]], organization: RpcOrganization, ): external_id_to_repo_config: dict[str, RepositoryConfig] = {} @@ -365,7 +354,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: dict[str, Any] ) -> RepositoryConfig: """ Builds final dict containing all necessary data to create the repository diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py deleted file mode 100644 index 9f63922c72d2..000000000000 --- a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py +++ /dev/null @@ -1,205 +0,0 @@ -from unittest.mock import MagicMock, patch - -from sentry.constants import ObjectStatus -from sentry.integrations.github.integration import GitHubIntegrationProvider -from sentry.integrations.github.tasks.sync_repos_on_install_change import ( - sync_repos_on_install_change, -) -from sentry.models.repository import Repository -from sentry.silo.base import SiloMode -from sentry.testutils.cases import IntegrationTestCase -from sentry.testutils.silo import assume_test_silo_mode, control_silo_test - -FEATURE_FLAG = "organizations:github-repo-auto-sync" - - -@control_silo_test -@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") -class SyncReposOnInstallChangeTestCase(IntegrationTestCase): - provider = GitHubIntegrationProvider - base_url = "https://api.github.com" - key = "github" - - def _make_repos_added(self): - return [ - {"id": 1, "full_name": "getsentry/sentry", "private": False}, - {"id": 2, "full_name": "getsentry/snuba", "private": False}, - ] - - def _make_repos_removed(self): - return [ - {"id": 3, "full_name": "getsentry/old-repo", "private": False}, - ] - - def test_repos_added(self, _: MagicMock) -> None: - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") - - assert len(repos) == 2 - assert repos[0].name == "getsentry/sentry" - assert repos[0].provider == "integrations:github" - assert repos[0].integration_id == self.integration.id - assert repos[1].name == "getsentry/snuba" - - def test_repos_removed(self, _: MagicMock) -> None: - with assume_test_silo_mode(SiloMode.CELL): - repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/old-repo", - external_id="3", - provider="integrations:github", - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="removed", - repos_added=[], - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - repo.refresh_from_db() - assert repo.status == ObjectStatus.DISABLED - - def test_mixed_add_and_remove(self, _: MagicMock) -> None: - with assume_test_silo_mode(SiloMode.CELL): - old_repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/old-repo", - external_id="3", - provider="integrations:github", - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - old_repo.refresh_from_db() - assert old_repo.status == ObjectStatus.DISABLED - - active_repos = Repository.objects.filter( - organization_id=self.organization.id, - status=ObjectStatus.ACTIVE, - ).order_by("name") - assert len(active_repos) == 2 - assert active_repos[0].name == "getsentry/sentry" - assert active_repos[1].name == "getsentry/snuba" - - def test_multi_org(self, _: MagicMock) -> None: - other_org = self.create_organization(owner=self.user) - self.create_organization_integration( - organization_id=other_org.id, - integration=self.integration, - ) - - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - repos_org1 = Repository.objects.filter(organization_id=self.organization.id) - repos_org2 = Repository.objects.filter(organization_id=other_org.id) - - assert len(repos_org1) == 2 - assert len(repos_org2) == 2 - - def test_missing_integration(self, _: MagicMock) -> None: - sync_repos_on_install_change( - integration_id=0, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - assert Repository.objects.count() == 0 - - def test_inactive_integration(self, _: MagicMock) -> None: - self.integration.update(status=ObjectStatus.DISABLED) - - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - assert Repository.objects.count() == 0 - - def test_feature_flag_off(self, _: MagicMock) -> None: - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - assert Repository.objects.count() == 0 - - def test_empty_repos_is_noop(self, _: MagicMock) -> None: - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=[], - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - assert Repository.objects.count() == 0 - - def test_does_not_disable_already_disabled_repos(self, _: MagicMock) -> None: - with assume_test_silo_mode(SiloMode.CELL): - repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/old-repo", - external_id="3", - provider="integrations:github", - integration_id=self.integration.id, - status=ObjectStatus.DISABLED, - ) - - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="removed", - repos_added=[], - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - repo.refresh_from_db() - assert repo.status == ObjectStatus.DISABLED diff --git a/tests/sentry/integrations/github/test_webhook.py b/tests/sentry/integrations/github/test_webhooks.py similarity index 82% rename from tests/sentry/integrations/github/test_webhook.py rename to tests/sentry/integrations/github/test_webhooks.py index 843b76e2e4dd..bd3b637b473a 100644 --- a/tests/sentry/integrations/github/test_webhook.py +++ b/tests/sentry/integrations/github/test_webhooks.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta, timezone -from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -20,11 +19,7 @@ ) from sentry import options from sentry.constants import ObjectStatus -from sentry.integrations.github.webhook import ( - GitHubIntegrationsWebhookEndpoint, - InstallationRepositoriesEventWebhook, -) -from sentry.integrations.github.webhook_types import InstallationRepositoriesEvent +from sentry.integrations.github.webhook import GitHubIntegrationsWebhookEndpoint from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.integration import integration_service @@ -368,297 +363,6 @@ def test_installation_deleted_skips_codecov_unlink_when_app_ids_dont_match( mock_codecov_unlink.assert_not_called() -@control_silo_test -class InstallationRepositoriesEventWebhookTest(APITestCase): - def setUp(self) -> None: - self.url = "/extensions/github/webhook/" - self.secret = "b3002c3e321d4b7880360d397db2ccfd" - options.set("github-app.webhook-secret", self.secret) - - def _make_event(self, action="added", repos_added=None, repos_removed=None): - return json.dumps( - { - "action": action, - "installation": {"id": 2}, - "repositories_added": repos_added or [], - "repositories_removed": repos_removed or [], - "repository_selection": "selected", - "sender": {"id": 1, "login": "octocat"}, - } - ) - - def _compute_signatures(self, body: str) -> tuple[str, str]: - sha1 = GitHubIntegrationsWebhookEndpoint.compute_signature( - "sha1", body.encode(), self.secret - ) - sha256 = GitHubIntegrationsWebhookEndpoint.compute_signature( - "sha256", body.encode(), self.secret - ) - return f"sha1={sha1}", f"sha256={sha256}" - - @patch("sentry.integrations.github.webhook.InstallationRepositoriesEventWebhook.__call__") - def test_webhook_dispatches_to_handler(self, mock_call: MagicMock) -> None: - """Verify the endpoint routes installation_repositories events to the correct handler.""" - body = self._make_event( - repos_added=[{"id": 1, "full_name": "getsentry/sentry", "private": False}], - ) - sha1, sha256 = self._compute_signatures(body) - - response = self.client.post( - path=self.url, - data=body, - content_type="application/json", - HTTP_X_GITHUB_EVENT="installation_repositories", - HTTP_X_HUB_SIGNATURE=sha1, - HTTP_X_HUB_SIGNATURE_256=sha256, - HTTP_X_GITHUB_DELIVERY=str(uuid4()), - ) - assert response.status_code == 204 - assert mock_call.called - - def test_end_to_end_repos_added(self) -> None: - """Full end-to-end: webhook URL → handler → task → Repository rows created.""" - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - self.create_integration( - name="octocat", - organization=self.organization, - external_id="2", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - - body = self._make_event( - repos_added=[ - {"id": 10, "full_name": "getsentry/sentry", "private": False}, - {"id": 20, "full_name": "getsentry/snuba", "private": False}, - ], - ) - sha1, sha256 = self._compute_signatures(body) - - with self.feature("organizations:github-repo-auto-sync"), self.tasks(): - response = self.client.post( - path=self.url, - data=body, - content_type="application/json", - HTTP_X_GITHUB_EVENT="installation_repositories", - HTTP_X_HUB_SIGNATURE=sha1, - HTTP_X_HUB_SIGNATURE_256=sha256, - HTTP_X_GITHUB_DELIVERY=str(uuid4()), - ) - assert response.status_code == 204 - - with assume_test_silo_mode(SiloMode.CELL): - repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") - - assert len(repos) == 2 - assert repos[0].name == "getsentry/sentry" - assert repos[0].provider == "integrations:github" - assert repos[1].name == "getsentry/snuba" - - def test_end_to_end_repos_removed(self) -> None: - """Full end-to-end: webhook URL → handler → task → Repository disabled.""" - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - integration = self.create_integration( - name="octocat", - organization=self.organization, - external_id="2", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - - with assume_test_silo_mode(SiloMode.CELL): - repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/old-repo", - external_id="30", - provider="integrations:github", - integration_id=integration.id, - status=ObjectStatus.ACTIVE, - ) - - body = self._make_event( - action="removed", - repos_removed=[{"id": 30, "full_name": "getsentry/old-repo", "private": False}], - ) - sha1, sha256 = self._compute_signatures(body) - - with self.feature("organizations:github-repo-auto-sync"), self.tasks(): - response = self.client.post( - path=self.url, - data=body, - content_type="application/json", - HTTP_X_GITHUB_EVENT="installation_repositories", - HTTP_X_HUB_SIGNATURE=sha1, - HTTP_X_HUB_SIGNATURE_256=sha256, - HTTP_X_GITHUB_DELIVERY=str(uuid4()), - ) - assert response.status_code == 204 - - with assume_test_silo_mode(SiloMode.CELL): - repo.refresh_from_db() - assert repo.status == ObjectStatus.DISABLED - - @patch( - "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" - ) - def test_handler_dispatches_task_on_repos_added(self, mock_apply_async: MagicMock) -> None: - """Test the handler class directly — repos_added dispatches the async task.""" - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - integration = self.create_integration( - name="octocat", - organization=self.organization, - external_id="2", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - - handler = InstallationRepositoriesEventWebhook() - handler( - event={ - "installation": {"id": 2}, - "action": "added", - "repositories_added": [ - {"id": 10, "full_name": "getsentry/sentry", "private": False} - ], - "repositories_removed": [], - "repository_selection": "selected", - "sender": {"id": 1, "login": "octocat"}, - } - ) - - mock_apply_async.assert_called_once() - kwargs = mock_apply_async.call_args[1]["kwargs"] - assert kwargs["integration_id"] == integration.id - assert kwargs["action"] == "added" - assert len(kwargs["repos_added"]) == 1 - assert kwargs["repos_added"][0]["id"] == 10 - assert kwargs["repos_removed"] == [] - assert kwargs["repository_selection"] == "selected" - - @patch( - "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" - ) - def test_handler_dispatches_task_on_repos_removed(self, mock_apply_async: MagicMock) -> None: - """Test the handler class directly — repos_removed dispatches the async task.""" - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - self.create_integration( - name="octocat", - organization=self.organization, - external_id="2", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - - handler = InstallationRepositoriesEventWebhook() - handler( - event={ - "installation": {"id": 2}, - "action": "removed", - "repositories_added": [], - "repositories_removed": [ - {"id": 20, "full_name": "getsentry/old-repo", "private": False} - ], - "repository_selection": "selected", - "sender": {"id": 1, "login": "octocat"}, - } - ) - - mock_apply_async.assert_called_once() - kwargs = mock_apply_async.call_args[1]["kwargs"] - assert kwargs["action"] == "removed" - assert len(kwargs["repos_removed"]) == 1 - - @patch( - "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" - ) - def test_handler_skips_when_no_repos(self, mock_apply_async: MagicMock) -> None: - """No repos added or removed — task should not be dispatched.""" - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - self.create_integration( - name="octocat", - organization=self.organization, - external_id="2", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - - handler = InstallationRepositoriesEventWebhook() - handler( - event={ - "installation": {"id": 2}, - "action": "added", - "repositories_added": [], - "repositories_removed": [], - "repository_selection": "selected", - "sender": {"id": 1, "login": "octocat"}, - } - ) - - mock_apply_async.assert_not_called() - - @patch( - "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" - ) - def test_handler_skips_when_malformed_event(self, mock_apply_async: MagicMock) -> None: - """Malformed event missing required keys — handler returns early.""" - handler = InstallationRepositoriesEventWebhook() - malformed_event = cast( - InstallationRepositoriesEvent, - {"repositories_added": [{"id": 1}], "repositories_removed": []}, - ) - handler(event=malformed_event) - - mock_apply_async.assert_not_called() - - @patch( - "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" - ) - def test_handler_skips_when_integration_not_found(self, mock_apply_async: MagicMock) -> None: - """Integration doesn't exist in Sentry — handler returns early.""" - handler = InstallationRepositoriesEventWebhook() - handler( - event={ - "installation": {"id": 99999}, - "action": "added", - "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], - "repositories_removed": [], - "repository_selection": "selected", - "sender": {"id": 1, "login": "octocat"}, - } - ) - - mock_apply_async.assert_not_called() - - @patch( - "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" - ) - def test_handler_propagates_host_for_ghe(self, mock_apply_async: MagicMock) -> None: - """GitHub Enterprise uses host prefix for external_id.""" - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - self.create_integration( - name="octocat", - organization=self.organization, - external_id="github.mycompany.com:2", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - - handler = InstallationRepositoriesEventWebhook() - handler( - event={ - "installation": {"id": 2}, - "action": "added", - "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], - "repositories_removed": [], - "repository_selection": "selected", - "sender": {"id": 1, "login": "octocat"}, - }, - host="github.mycompany.com", - ) - - mock_apply_async.assert_called_once() - - class PushEventWebhookTest(APITestCase): def setUp(self) -> None: self.url = "/extensions/github/webhook/" diff --git a/tests/sentry/integrations/services/repository/test_impl.py b/tests/sentry/integrations/services/repository/test_impl.py deleted file mode 100644 index a92df36cc470..000000000000 --- a/tests/sentry/integrations/services/repository/test_impl.py +++ /dev/null @@ -1,162 +0,0 @@ -from sentry.constants import ObjectStatus -from sentry.integrations.services.repository.service import repository_service -from sentry.models.repository import Repository -from sentry.testutils.cases import TestCase -from sentry.testutils.silo import cell_silo_test - - -@cell_silo_test -class DisableRepositoriesByExternalIdsTest(TestCase): - def setUp(self) -> None: - self.integration = self.create_integration( - organization=self.organization, - external_id="1", - provider="github", - ) - self.provider = "integrations:github" - - def test_disables_matching_active_repos(self) -> None: - repo1 = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/sentry", - external_id="100", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - repo2 = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/snuba", - external_id="200", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - - repository_service.disable_repositories_by_external_ids( - organization_id=self.organization.id, - integration_id=self.integration.id, - provider=self.provider, - external_ids=["100", "200"], - ) - - repo1.refresh_from_db() - repo2.refresh_from_db() - assert repo1.status == ObjectStatus.DISABLED - assert repo2.status == ObjectStatus.DISABLED - - def test_does_not_disable_already_disabled_repos(self) -> None: - repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/sentry", - external_id="100", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.DISABLED, - ) - - repository_service.disable_repositories_by_external_ids( - organization_id=self.organization.id, - integration_id=self.integration.id, - provider=self.provider, - external_ids=["100"], - ) - - repo.refresh_from_db() - assert repo.status == ObjectStatus.DISABLED - - def test_does_not_affect_repos_from_other_integrations(self) -> None: - other_integration = self.create_integration( - organization=self.organization, - external_id="2", - provider="github", - ) - repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/sentry", - external_id="100", - provider=self.provider, - integration_id=other_integration.id, - status=ObjectStatus.ACTIVE, - ) - - repository_service.disable_repositories_by_external_ids( - organization_id=self.organization.id, - integration_id=self.integration.id, - provider=self.provider, - external_ids=["100"], - ) - - repo.refresh_from_db() - assert repo.status == ObjectStatus.ACTIVE - - def test_does_not_affect_repos_from_other_orgs(self) -> None: - other_org = self.create_organization() - repo = Repository.objects.create( - organization_id=other_org.id, - name="getsentry/sentry", - external_id="100", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - - repository_service.disable_repositories_by_external_ids( - organization_id=self.organization.id, - integration_id=self.integration.id, - provider=self.provider, - external_ids=["100"], - ) - - repo.refresh_from_db() - assert repo.status == ObjectStatus.ACTIVE - - def test_only_disables_specified_external_ids(self) -> None: - repo_to_disable = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/sentry", - external_id="100", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - repo_to_keep = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/snuba", - external_id="200", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - - repository_service.disable_repositories_by_external_ids( - organization_id=self.organization.id, - integration_id=self.integration.id, - provider=self.provider, - external_ids=["100"], - ) - - repo_to_disable.refresh_from_db() - repo_to_keep.refresh_from_db() - assert repo_to_disable.status == ObjectStatus.DISABLED - assert repo_to_keep.status == ObjectStatus.ACTIVE - - def test_empty_external_ids_is_noop(self) -> None: - repo = Repository.objects.create( - organization_id=self.organization.id, - name="getsentry/sentry", - external_id="100", - provider=self.provider, - integration_id=self.integration.id, - status=ObjectStatus.ACTIVE, - ) - - repository_service.disable_repositories_by_external_ids( - organization_id=self.organization.id, - integration_id=self.integration.id, - provider=self.provider, - external_ids=[], - ) - - repo.refresh_from_db() - assert repo.status == ObjectStatus.ACTIVE diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py index eb2f7f5c470e..8abbfcd5de55 100644 --- a/tests/sentry/middleware/integrations/parsers/test_github.py +++ b/tests/sentry/middleware/integrations/parsers/test_github.py @@ -139,52 +139,6 @@ def test_get_integration_from_request(self) -> None: result = parser.get_integration_from_request() assert result == integration - @override_settings(SILO_MODE=SiloMode.CONTROL) - @override_cells(cell_config) - def test_installation_repositories_routes_to_control_silo(self) -> None: - request = self.factory.post( - self.path, - data={ - "installation": {"id": "1"}, - "repositories_added": [], - "repositories_removed": [], - }, - content_type="application/json", - headers={ - "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION_REPOSITORIES.value, - }, - ) - parser = GithubRequestParser(request=request, response_handler=self.get_response) - assert parser.should_route_to_control_silo(parsed_event={}, request=request) - - @override_settings(SILO_MODE=SiloMode.CONTROL) - @override_cells(cell_config) - def test_installation_routes_to_control_silo(self) -> None: - request = self.factory.post( - self.path, - data={"installation": {"id": "1"}}, - content_type="application/json", - headers={ - "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION.value, - }, - ) - parser = GithubRequestParser(request=request, response_handler=self.get_response) - assert parser.should_route_to_control_silo(parsed_event={}, request=request) - - @override_settings(SILO_MODE=SiloMode.CONTROL) - @override_cells(cell_config) - def test_push_does_not_route_to_control_silo(self) -> None: - request = self.factory.post( - self.path, - data={"installation": {"id": "1"}}, - content_type="application/json", - headers={ - "X-GITHUB-EVENT": GithubWebhookType.PUSH.value, - }, - ) - parser = GithubRequestParser(request=request, response_handler=self.get_response) - assert not parser.should_route_to_control_silo(parsed_event={}, request=request) - @override_settings(SILO_MODE=SiloMode.CONTROL) @override_cells(cell_config) def test_webhook_outbox_creation(self) -> None: From 05bf2cbd11a8ac681b485a317e343a318c9d6588 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Fri, 3 Apr 2026 19:41:00 +0000 Subject: [PATCH 20/35] Revert "fix(github): Add `sync_repos_on_install_change` to TASKWORKER_IMPORTS (#112201)" This reverts commit 371be3fce99a2c48b61b17382ace969ea41bdb8d. Co-authored-by: wedamija <6288560+wedamija@users.noreply.github.com> --- src/sentry/conf/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 057cff896627..12f13726a2d3 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -882,7 +882,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.integrations.github.tasks.codecov_account_unlink", "sentry.integrations.github.tasks.link_all_repos", "sentry.integrations.github.tasks.pr_comment", - "sentry.integrations.github.tasks.sync_repos_on_install_change", "sentry.integrations.gitlab.tasks", "sentry.integrations.jira.tasks", "sentry.integrations.opsgenie.tasks", From ea0c749627b62b5bc135393aac58a9fe97969b5e Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 3 Apr 2026 13:14:26 -0700 Subject: [PATCH 21/35] feat(seer): Implement the dropdown to save defaultAutomatedRunStoppingPoint (#112211) Just the basics as a dropdown. Following this will be the summary blurb and bulk-edit button SCR-20260403-jyzo --- static/app/types/organization.tsx | 2 + .../seer/overview/autofixOverviewSection.tsx | 67 ++++++++++++- .../overview/utils/seerPreferredAgent.spec.ts | 8 +- .../seer/overview/utils/seerPreferredAgent.ts | 5 +- .../seer/overview/utils/seerStoppingPoint.ts | 94 +++++++++++++++++++ tests/js/fixtures/organization.ts | 2 + 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 static/app/views/settings/seer/overview/utils/seerStoppingPoint.ts diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index 1db0d39c735c..2467c50b1452 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -1,3 +1,4 @@ +import type {AutofixStoppingPoint} from 'sentry/components/events/autofix/types'; import type {AggregationOutputType} from 'sentry/utils/discover/fields'; import type { DatasetSource, @@ -64,6 +65,7 @@ export interface Organization extends OrganizationSummary { dataScrubber: boolean; dataScrubberDefaults: boolean; debugFilesRole: string; + defaultAutomatedRunStoppingPoint: AutofixStoppingPoint; defaultCodeReviewTriggers: CodeReviewTrigger[]; defaultCodingAgent: string | null; defaultCodingAgentIntegrationId: string | number | null; diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index 9e17fee181cb..e198fdd13938 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -29,11 +29,17 @@ import {useProjects} from 'sentry/utils/useProjects'; import { getPreferredAgentMutationOptions, useFetchPreferredAgent, - useFetchPreferredAgentOptions, + useFetchAgentOptions, useBulkMutateSelectedAgent, } from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; import {useBulkMutateCreatePr} from 'sentry/views/settings/seer/seerAgentHooks'; +import { + getDefaultStoppingPointMutationOptions, + getDefaultStoppingPointValue, + useFetchStoppingPointOptions, +} from './utils/seerStoppingPoint'; + export function useAutofixOverviewData() { const organization = useOrganization(); @@ -127,6 +133,8 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} projects={projects} projectsWithCreatePr={projectsWithCreatePr} /> + + ); } @@ -151,7 +159,7 @@ function AgentNameForm({ setIsBulkMutatingAgent: (value: boolean) => void; }) { const preferredAgent = useFetchPreferredAgent({organization}); - const codingAgentSelectOptions = useFetchPreferredAgentOptions({organization}); + const codingAgentSelectOptions = useFetchAgentOptions({organization}); const codingAgentMutationOptions = getPreferredAgentMutationOptions({organization}); const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ projects: projects.filter(p => !projectsIdsWithPreferredAgent.has(p.id)), @@ -392,3 +400,58 @@ function CreatePrForm({ ); } + +function StoppingPointForm({ + organization, + canWrite, +}: { + canWrite: boolean; + organization: Organization; +}) { + const stoppingPointMutationOpts = getDefaultStoppingPointMutationOptions({ + organization, + }); + + const initialValue = getDefaultStoppingPointValue(organization); + const preferredAgent = useFetchPreferredAgent({organization}); + const options = useFetchStoppingPointOptions({ + agent: preferredAgent.data, + organization, + }); + + return ( + + {field => ( + + + ), + } + )} + > + + + + + + )} + + ); +} diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts index 8ea7f4d9a2fc..3097deb833d9 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -12,7 +12,7 @@ import {useMutation} from 'sentry/utils/queryClient'; import { useBulkMutateSelectedAgent, useFetchPreferredAgent, - useFetchPreferredAgentOptions, + useFetchAgentOptions, getPreferredAgentMutationOptions, } from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; @@ -133,7 +133,7 @@ describe('seerPreferredAgent', () => { it('includes "seer" as first option plus integration options', async () => { mockIntegrationsEndpoint(); - const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + const {result} = renderHookWithProviders(useFetchAgentOptions, { initialProps: {organization}, organization, }); @@ -160,7 +160,7 @@ describe('seerPreferredAgent', () => { ], }); - const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + const {result} = renderHookWithProviders(useFetchAgentOptions, { initialProps: {organization}, organization, }); @@ -177,7 +177,7 @@ describe('seerPreferredAgent', () => { it('returns only "seer" when there are no integrations', async () => { mockIntegrationsEndpoint({integrations: []}); - const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + const {result} = renderHookWithProviders(useFetchAgentOptions, { initialProps: {organization}, organization, }); diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts index a0f7fb6f1813..ce7bd5f611e0 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -51,13 +51,16 @@ export function useFetchPreferredAgent({organization}: {organization: Organizati return query; } -export function useFetchPreferredAgentOptions({ +export function useFetchAgentOptions({ organization, + enabled = true, }: { organization: Organization; + enabled?: boolean; }) { return useQuery({ ...organizationIntegrationsCodingAgents(organization), + enabled, select: data => { return [ {value: 'seer', label: t('Seer Agent')} as SelectValue, diff --git a/static/app/views/settings/seer/overview/utils/seerStoppingPoint.ts b/static/app/views/settings/seer/overview/utils/seerStoppingPoint.ts new file mode 100644 index 000000000000..da78bd82f8c7 --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerStoppingPoint.ts @@ -0,0 +1,94 @@ +import {updateOrganization} from 'sentry/actionCreators/organizations'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {fetchMutation, mutationOptions} from 'sentry/utils/queryClient'; +import {useFetchAgentOptions} from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; + +type SelectValue = 'off' | 'root_cause' | 'code'; +type SelectOptions = {label: string; value: SelectValue}; + +export function getDefaultStoppingPointValue(organization: Organization): SelectValue { + if ([null, undefined, 'off'].includes(organization.defaultAutofixAutomationTuning)) { + return 'off'; + } + return organization.defaultAutomatedRunStoppingPoint === 'root_cause' + ? 'root_cause' + : 'code'; +} + +export function useFetchStoppingPointOptions({ + organization, + agent, +}: { + agent: undefined | 'seer' | CodingAgentIntegration; + organization: Organization; +}): SelectOptions[] { + const autoOpenPrs = organization.autoOpenPrs; + + const isSeerAgent = agent === 'seer'; + const codingAgentSelectOptions = useFetchAgentOptions({ + organization, + enabled: !isSeerAgent, + }); + + if (isSeerAgent) { + return [ + {value: 'off', label: t('No Automation')}, + {value: 'root_cause', label: t('Automate Root Cause Analysis')}, + { + value: 'code', + label: autoOpenPrs + ? t('Draft a Pull Request with Seer') + : t('Write Code Changes with Seer'), + }, + ]; + } + + const agentLabel = codingAgentSelectOptions.data?.find( + o => o.value === agent || (typeof o.value === 'object' && o.value.id === agent?.id) + )?.label; + + return [ + {value: 'off', label: t('No Automation')}, + {value: 'root_cause', label: t('Automate Root Cause Analysis')}, + { + value: 'code', + label: autoOpenPrs + ? agentLabel + ? t('Draft a Pull Request with %s', agentLabel) + : t('Draft a Pull Request') + : agentLabel + ? t('Propose Changes with %s', agentLabel) + : t('Propose Changes'), + }, + ]; +} + +export function getDefaultStoppingPointMutationOptions({ + organization, +}: { + organization: Organization; +}) { + return mutationOptions({ + mutationFn: ({stoppingPoint}: {stoppingPoint: SelectValue}) => { + return fetchMutation({ + method: 'PUT', + url: `/organizations/${organization.slug}/`, + data: + stoppingPoint === 'off' + ? {defaultAutofixAutomationTuning: 'off'} + : { + defaultAutofixAutomationTuning: 'medium', + defaultAutomatedRunStoppingPoint: + stoppingPoint === 'root_cause' + ? 'root_cause' + : organization.autoOpenPrs + ? 'open_pr' + : 'code_changes', + }, + }); + }, + onSuccess: updateOrganization, + }); +} diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index ba32d2dac427..89c5186b9692 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -1,5 +1,6 @@ import {OrgRoleListFixture, TeamRoleListFixture} from 'sentry-fixture/roleList'; +import {AutofixStoppingPoint} from 'sentry/components/events/autofix/types'; import type {Organization} from 'sentry/types/organization'; export function OrganizationFixture(params: Partial = {}): Organization { @@ -54,6 +55,7 @@ export function OrganizationFixture(params: Partial = {}): Organiz dataScrubberDefaults: false, dateCreated: new Date().toISOString(), debugFilesRole: '', + defaultAutomatedRunStoppingPoint: AutofixStoppingPoint.ROOT_CAUSE, defaultCodeReviewTriggers: [], defaultCodingAgentIntegrationId: null, defaultCodingAgent: 'seer', From 68f4a487b109e0f1d3716e1c4f3330ea7ee210b7 Mon Sep 17 00:00:00 2001 From: Brendan Hy Date: Fri, 3 Apr 2026 13:34:17 -0700 Subject: [PATCH 22/35] chore: bump sentry-protos 0.8.10 (#112225) --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5bf7ba311304..36d70464c304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ dependencies = [ "sentry-forked-email-reply-parser>=0.5.12.post1", "sentry-kafka-schemas>=2.1.27", "sentry-ophio>=1.1.3", - "sentry-protos>=0.8.8", + "sentry-protos>=0.8.10", "sentry-redis-tools>=0.5.0", "sentry-relay>=0.9.25", "sentry-sdk[http2]>=2.47.0", diff --git a/uv.lock b/uv.lock index 20e8272ad51e..fee5cf81934c 100644 --- a/uv.lock +++ b/uv.lock @@ -2372,7 +2372,7 @@ requires-dist = [ { name = "sentry-forked-email-reply-parser", specifier = ">=0.5.12.post1" }, { name = "sentry-kafka-schemas", specifier = ">=2.1.27" }, { name = "sentry-ophio", specifier = ">=1.1.3" }, - { name = "sentry-protos", specifier = ">=0.8.8" }, + { name = "sentry-protos", specifier = ">=0.8.10" }, { name = "sentry-redis-tools", specifier = ">=0.5.0" }, { name = "sentry-relay", specifier = ">=0.9.25" }, { name = "sentry-sdk", extras = ["http2"], specifier = ">=2.47.0" }, @@ -2545,7 +2545,7 @@ wheels = [ [[package]] name = "sentry-protos" -version = "0.8.8" +version = "0.8.10" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "grpc-stubs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2554,7 +2554,7 @@ dependencies = [ { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_protos-0.8.8-py3-none-any.whl", hash = "sha256:04c0fa7d28d0392d468d37c5c53d2c6fae627b56616217e385c53e06ffbc8a0d" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_protos-0.8.10-py3-none-any.whl", hash = "sha256:24017f6e46537704f23d81658eebe2227f33b87efcf97a3f653df49c423cf822" }, ] [[package]] From 8a0a9a00cf5ba9d5a31e2ba5d74c8d42ebe0ba31 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Fri, 3 Apr 2026 21:18:25 +0000 Subject: [PATCH 23/35] Revert "fix(webhooks): Split out the metric alert action to be in a task (#112177)" This reverts commit b4f2897a34c2b59e8a1921501f08cffe47c03c2c. Co-authored-by: Christinarlong <60594860+Christinarlong@users.noreply.github.com> --- src/sentry/incidents/action_handlers.py | 11 +- .../integrations/services/integration/impl.py | 7 +- .../sentry_app_metric_alert_handler.py | 1 - .../rules/actions/notify_event_service.py | 46 +++- src/sentry/sentry_apps/metrics.py | 1 - src/sentry/sentry_apps/tasks/sentry_apps.py | 118 +------- src/sentry/sentry_apps/utils/webhooks.py | 29 +- .../test_sentry_app_metric_alert_handler.py | 1 - .../actions/test_notify_event_service.py | 104 +------ .../sentry_apps/tasks/test_sentry_apps.py | 260 ------------------ 10 files changed, 52 insertions(+), 526 deletions(-) diff --git a/src/sentry/incidents/action_handlers.py b/src/sentry/incidents/action_handlers.py index 206406c7dbe7..e6a8b00fc500 100644 --- a/src/sentry/incidents/action_handlers.py +++ b/src/sentry/incidents/action_handlers.py @@ -462,15 +462,22 @@ def send_alert( incident_serialized_response = serialize(incident, serializer=IncidentSerializer()) - send_incident_alert_notification( + success = send_incident_alert_notification( notification_context=notification_context, alert_context=alert_context, metric_issue_context=metric_issue_context, incident_serialized_response=incident_serialized_response, organization=incident.organization, - project_id=project.id, notification_uuid=notification_uuid, ) + if success: + self.record_alert_sent_analytics( + organization_id=incident.organization.id, + project_id=project.id, + alert_id=incident.alert_rule.id, + external_id=action.sentry_app_id, + notification_uuid=notification_uuid, + ) def format_duration(minutes): diff --git a/src/sentry/integrations/services/integration/impl.py b/src/sentry/integrations/services/integration/impl.py index 1a628be719f7..f33d6e750b05 100644 --- a/src/sentry/integrations/services/integration/impl.py +++ b/src/sentry/integrations/services/integration/impl.py @@ -45,6 +45,7 @@ serialize_integration_external_project, serialize_organization_integration, ) +from sentry.rules.actions.notify_event_service import find_alert_rule_action_ui_component from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.metrics import ( SentryAppEventType, @@ -53,11 +54,7 @@ ) from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation -from sentry.sentry_apps.utils.webhooks import ( - MetricAlertActionType, - SentryAppResourceType, - find_alert_rule_action_ui_component, -) +from sentry.sentry_apps.utils.webhooks import MetricAlertActionType, SentryAppResourceType from sentry.shared_integrations.exceptions import ApiError from sentry.utils import json from sentry.utils.sentry_apps import send_and_save_webhook_request diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py index 2c9b1f79c09d..8037d57b8214 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/sentry_app_metric_alert_handler.py @@ -54,6 +54,5 @@ def send_alert( metric_issue_context=metric_issue_context, incident_serialized_response=incident_serialized_response, organization=organization, - project_id=project.id, notification_uuid=notification_uuid, ) diff --git a/src/sentry/rules/actions/notify_event_service.py b/src/sentry/rules/actions/notify_event_service.py index d794f172b669..31fe09bf88bc 100644 --- a/src/sentry/rules/actions/notify_event_service.py +++ b/src/sentry/rules/actions/notify_event_service.py @@ -13,13 +13,15 @@ NotificationContext, ) from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization from sentry.plugins.base import plugins from sentry.rules.actions.base import EventAction from sentry.rules.actions.services import PluginService from sentry.rules.base import CallbackFuture +from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent from sentry.sentry_apps.services.app import RpcSentryAppService, app_service -from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app, send_metric_alert_webhook +from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app from sentry.services.eventstore.models import GroupEvent from sentry.utils import json, metrics from sentry.utils.forms import set_field_choices @@ -62,15 +64,15 @@ def send_incident_alert_notification( metric_issue_context: MetricIssueContext, incident_serialized_response: IncidentSerializerResponse, organization: Organization, - project_id: int, notification_uuid: str | None = None, -) -> None: +) -> bool: """ When a metric alert is triggered, send incident data to the SentryApp's webhook. :param action: The triggered `AlertRuleTriggerAction`. :param incident: The `Incident` for which to build a payload. - :param metric_value: The value of the metric that triggered this alert to fire. - :param project_id: project id will be used for analytics after sending the webhook. + :param metric_value: The value of the metric that triggered this alert to + fire. + :return: """ incident_attachment = build_incident_attachment( alert_context, @@ -83,16 +85,40 @@ def send_incident_alert_notification( if notification_context.sentry_app_id is None: raise ValueError("Sentry app ID is required") - send_metric_alert_webhook.delay( - sentry_app_id=int(notification_context.sentry_app_id), + success = integration_service.send_incident_alert_notification( + sentry_app_id=notification_context.sentry_app_id, new_status=metric_issue_context.new_status.value, incident_attachment_json=json.dumps(incident_attachment), organization_id=organization.id, - project_id=project_id, - alert_id=alert_context.action_identifier_id, - notification_uuid=notification_uuid, + # TODO(iamrajjoshi): The rest of the params are unused + action_id=-1, + incident_id=-1, + metric_value=-1, + ) + return success + + +def find_alert_rule_action_ui_component(app_platform_event: AppPlatformEvent) -> bool: + """ + Loop through the triggers for the alert rule event. For each trigger, check + if an action is an alert rule UI Component + """ + triggers = ( + getattr(app_platform_event, "data", {}) + .get("metric_alert", {}) + .get("alert_rule", {}) + .get("triggers", []) ) + actions = [ + action + for trigger in triggers + for action in trigger.get("actions", {}) + if (action.get("type") == "sentry_app" and action.get("settings") is not None) + ] + + return bool(len(actions)) + class NotifyEventServiceForm(forms.Form): service = forms.ChoiceField(choices=()) diff --git a/src/sentry/sentry_apps/metrics.py b/src/sentry/sentry_apps/metrics.py index be0004ad528b..72eb5fea395f 100644 --- a/src/sentry/sentry_apps/metrics.py +++ b/src/sentry/sentry_apps/metrics.py @@ -60,7 +60,6 @@ class SentryAppWebhookFailureReason(StrEnum): EVENT_NOT_IN_SERVCEHOOK = "event_not_in_servicehook" MISSING_ISSUE_OCCURRENCE = "missing_issue_occurrence" MISSING_USER = "missing_user" - MULTIPLE_INSTALLATIONS = "multiple_installations" class SentryAppWebhookHaltReason(StrEnum): diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 518c6d0b5233..3800cb619db7 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -17,7 +17,6 @@ from sentry.analytics.events.alert_rule_ui_component_webhook_sent import ( AlertRuleUiComponentWebhookSentEvent, ) -from sentry.analytics.events.alert_sent import AlertSentEvent from sentry.analytics.events.comment_webhooks import ( CommentCreatedEvent, CommentDeletedEvent, @@ -37,7 +36,6 @@ from sentry.db.models.base import Model from sentry.exceptions import RestrictedIPAddress from sentry.hybridcloud.rpc.caching import cell_caching_service -from sentry.incidents.models.incident import INCIDENT_STATUS, IncidentStatus from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.activity import Activity from sentry.models.group import Group @@ -65,12 +63,7 @@ ) from sentry.sentry_apps.services.hook.service import hook_service from sentry.sentry_apps.utils.errors import SentryAppSentryError -from sentry.sentry_apps.utils.webhooks import ( - IssueAlertActionType, - MetricAlertActionType, - SentryAppResourceType, - find_alert_rule_action_ui_component, -) +from sentry.sentry_apps.utils.webhooks import IssueAlertActionType, SentryAppResourceType from sentry.services.eventstore.models import BaseEvent, Event, GroupEvent from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError from sentry.silo.base import SiloMode @@ -79,7 +72,7 @@ from sentry.types.rules import RuleFuture from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service -from sentry.utils import json, metrics +from sentry.utils import metrics from sentry.utils.function_cache import cache_func_for_models from sentry.utils.http import absolute_uri from sentry.utils.sentry_apps import send_and_save_webhook_request @@ -915,113 +908,6 @@ def regenerate_service_hooks_for_installation( ) -def _record_metric_alert_sent_analytics( - organization_id: int, - project_id: int, - alert_id: int, - sentry_app_id: int, - notification_uuid: str | None, -) -> None: - try: - analytics.record( - AlertSentEvent( - organization_id=organization_id, - project_id=project_id, - alert_id=alert_id, - alert_type="metric_alert", - provider="sentry_app", - external_id=str(sentry_app_id), - notification_uuid=notification_uuid, - ) - ) - except Exception as e: - sentry_sdk.capture_exception(e) - - -def _record_metric_alert_ui_component_analytics( - organization_id: int, - sentry_app_id: int, - app_platform_event: AppPlatformEvent, -) -> None: - if not find_alert_rule_action_ui_component(app_platform_event): - return - try: - analytics.record( - AlertRuleUiComponentWebhookSentEvent( - organization_id=organization_id, - sentry_app_id=sentry_app_id, - event=f"{app_platform_event.resource}.{app_platform_event.action}", - ) - ) - except Exception as e: - sentry_sdk.capture_exception(e) - - -@instrumented_task( - name="sentry.sentry_apps.tasks.sentry_apps.send_metric_alert_webhook", - namespace=sentryapp_tasks, - retry=Retry(times=3, delay=60 * 5), - silo_mode=SiloMode.CELL, -) -@retry_decorator -def send_metric_alert_webhook( - sentry_app_id: int, - new_status: int, - incident_attachment_json: str, - organization_id: int, - project_id: int, - alert_id: int, - notification_uuid: str | None = None, - **kwargs: Any, -) -> None: - try: - new_status_str = INCIDENT_STATUS[IncidentStatus(new_status)].lower() - event = SentryAppEventType( - f"{SentryAppResourceType.METRIC_ALERT}.{MetricAlertActionType(new_status_str)}" - ) - except ValueError as e: - sentry_sdk.capture_exception(e) - return - - with SentryAppInteractionEvent( - operation_type=SentryAppInteractionType.PREPARE_WEBHOOK, - event_type=event, - ).capture() as lifecycle: - sentry_app = app_service.get_sentry_app_by_id(id=sentry_app_id) - if sentry_app is None: - lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_SENTRY_APP) - return - - installations = app_service.get_many( - filter=dict( - organization_id=organization_id, - app_ids=[sentry_app.id], - status=SentryAppInstallationStatus.INSTALLED, - ) - ) - if not installations: - lifecycle.record_failure(SentryAppWebhookFailureReason.MISSING_INSTALLATION) - return - - if len(installations) > 1: - lifecycle.record_failure(SentryAppWebhookFailureReason.MULTIPLE_INSTALLATIONS) - return - - app_platform_event = AppPlatformEvent( - resource=SentryAppResourceType.METRIC_ALERT, - action=MetricAlertActionType(new_status_str), - install=installations[0], - data=json.loads(incident_attachment_json), - ) - - send_and_save_webhook_request(sentry_app, app_platform_event) - - _record_metric_alert_sent_analytics( - organization_id, project_id, alert_id, sentry_app.id, notification_uuid - ) - _record_metric_alert_ui_component_analytics(organization_id, sentry_app.id, app_platform_event) - - @instrumented_task( name="sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization", namespace=sentryapp_tasks, diff --git a/src/sentry/sentry_apps/utils/webhooks.py b/src/sentry/sentry_apps/utils/webhooks.py index c4f112e229aa..16f3dcb820eb 100644 --- a/src/sentry/sentry_apps/utils/webhooks.py +++ b/src/sentry/sentry_apps/utils/webhooks.py @@ -1,10 +1,5 @@ -from __future__ import annotations - from enum import StrEnum -from typing import TYPE_CHECKING, Any, Final - -if TYPE_CHECKING: - from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent +from typing import Final class SentryAppActionType(StrEnum): @@ -111,25 +106,3 @@ def map_sentry_app_webhook_events( # per-event-type (issue.created, project.deleted, etc.). These are valid # resources a Sentry App may subscribe to. VALID_EVENT_RESOURCES = EVENT_EXPANSION.keys() - - -def find_alert_rule_action_ui_component( - app_platform_event: AppPlatformEvent[dict[str, Any]], -) -> bool: - """ - Returns True if the metric alert event contains a sentry app action with UI component settings. - Used to gate recording of AlertRuleUiComponentWebhookSentEvent analytics. - """ - triggers = ( - getattr(app_platform_event, "data", {}) - .get("metric_alert", {}) - .get("alert_rule", {}) - .get("triggers", []) - ) - actions = [ - action - for trigger in triggers - for action in trigger.get("actions", []) - if (action.get("type") == "sentry_app" and action.get("settings") is not None) - ] - return bool(len(actions)) diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py index 92328e7e9328..2e691a8334cb 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py @@ -85,7 +85,6 @@ def test_send_alert(self, mock_send_incident_alert_notification: mock.MagicMock) alert_context=alert_context, metric_issue_context=metric_issue_context, organization=self.detector.project.organization, - project_id=self.detector.project.id, notification_uuid=notification_uuid, incident_serialized_response=get_incident_serializer(self.open_period), ) diff --git a/tests/sentry/rules/actions/test_notify_event_service.py b/tests/sentry/rules/actions/test_notify_event_service.py index 864ff8666b0e..ed459cae6b46 100644 --- a/tests/sentry/rules/actions/test_notify_event_service.py +++ b/tests/sentry/rules/actions/test_notify_event_service.py @@ -1,31 +1,19 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 -import pytest import responses from django.utils import timezone from requests.exceptions import HTTPError -from sentry.api.serializers import serialize from sentry.eventstream.types import EventStreamEventType from sentry.grouping.grouptype import ErrorGroupType -from sentry.incidents.endpoints.serializers.incident import IncidentSerializer -from sentry.incidents.models.incident import IncidentStatus -from sentry.incidents.typings.metric_detector import ( - AlertContext, - MetricIssueContext, - NotificationContext, -) from sentry.models.rule import Rule from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin -from sentry.rules.actions.notify_event_service import ( - NotifyEventServiceAction, - send_incident_alert_notification, -) +from sentry.rules.actions.notify_event_service import NotifyEventServiceAction from sentry.sentry_apps.tasks.sentry_apps import notify_sentry_app from sentry.silo.base import SiloMode from sentry.tasks.post_process import post_process_group -from sentry.testutils.cases import RuleTestCase, TestCase +from sentry.testutils.cases import RuleTestCase from sentry.testutils.helpers.eventprocessing import write_event_to_cache from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba @@ -260,91 +248,3 @@ def test_sentry_app_installed(self) -> None: results = rule.get_services() assert len(results) == 0 - - -class TestSendIncidentAlertNotification(TestCase): - def setUp(self) -> None: - self.project = self.create_project(organization=self.organization) - self.sentry_app = self.create_sentry_app(organization=self.organization) - self.alert_rule = self.create_alert_rule() - self.incident = self.create_incident(alert_rule=self.alert_rule) - self.alert_context = AlertContext.from_alert_rule_incident(self.alert_rule) - self.metric_issue_context = MetricIssueContext.from_legacy_models( - self.incident, IncidentStatus.CRITICAL, metric_value=100.0 - ) - self.incident_serialized_response = serialize( - self.incident, serializer=IncidentSerializer() - ) - self.notification_context = NotificationContext( - id=1, - sentry_app_id=self.sentry_app.id, - ) - self.notification_uuid = str(uuid4()) - - @patch("sentry.rules.actions.notify_event_service.send_metric_alert_webhook") - def test_dispatches_task_with_correct_kwargs(self, mock_task: MagicMock) -> None: - send_incident_alert_notification( - notification_context=self.notification_context, - alert_context=self.alert_context, - metric_issue_context=self.metric_issue_context, - incident_serialized_response=self.incident_serialized_response, - organization=self.organization, - project_id=self.project.id, - notification_uuid=self.notification_uuid, - ) - - mock_task.delay.assert_called_once() - call_kwargs = mock_task.delay.call_args.kwargs - assert call_kwargs["sentry_app_id"] == self.sentry_app.id - assert call_kwargs["new_status"] == IncidentStatus.CRITICAL.value - assert call_kwargs["organization_id"] == self.organization.id - assert call_kwargs["project_id"] == self.project.id - assert call_kwargs["alert_id"] == self.alert_rule.id - assert call_kwargs["notification_uuid"] == self.notification_uuid - - attachment = json.loads(call_kwargs["incident_attachment_json"]) - assert "metric_alert" in attachment - assert "description_title" in attachment - assert "description_text" in attachment - assert "web_url" in attachment - - @patch("sentry.rules.actions.notify_event_service.send_metric_alert_webhook") - def test_raises_when_sentry_app_id_is_none(self, mock_task: MagicMock) -> None: - notification_context_no_app = NotificationContext(id=1, sentry_app_id=None) - - with pytest.raises(ValueError, match="Sentry app ID is required"): - send_incident_alert_notification( - notification_context=notification_context_no_app, - alert_context=self.alert_context, - metric_issue_context=self.metric_issue_context, - incident_serialized_response=self.incident_serialized_response, - organization=self.organization, - project_id=self.project.id, - notification_uuid=self.notification_uuid, - ) - - mock_task.delay.assert_not_called() - - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") - def test_end_to_end_sends_webhook(self, safe_urlopen: MagicMock) -> None: - safe_urlopen.return_value = MagicMock(status_code=200, headers={}) - install = self.create_sentry_app_installation( - organization=self.organization, slug=self.sentry_app.slug - ) - - with self.tasks(): - send_incident_alert_notification( - notification_context=self.notification_context, - alert_context=self.alert_context, - metric_issue_context=self.metric_issue_context, - incident_serialized_response=self.incident_serialized_response, - organization=self.organization, - project_id=self.project.id, - notification_uuid=self.notification_uuid, - ) - - safe_urlopen.assert_called_once() - _, call_kwargs = safe_urlopen.call_args - body = json.loads(call_kwargs["data"]) - assert body["action"] == "critical" - assert body["installation"]["uuid"] == install.uuid diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py index 04f980fe8c0c..141cc6f7f250 100644 --- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py @@ -7,16 +7,11 @@ from requests import HTTPError from requests.exceptions import ChunkedEncodingError, ConnectionError, Timeout -from sentry.analytics.events.alert_rule_ui_component_webhook_sent import ( - AlertRuleUiComponentWebhookSentEvent, -) -from sentry.analytics.events.alert_sent import AlertSentEvent from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case from sentry.constants import SentryAppStatus from sentry.eventstream.types import EventStreamEventType from sentry.exceptions import RestrictedIPAddress -from sentry.incidents.models.incident import IncidentStatus from sentry.integrations.types import EventLifecycleOutcome from sentry.issues.ingest import save_issue_occurrence from sentry.models.activity import Activity @@ -31,7 +26,6 @@ process_resource_change_bound, regenerate_service_hooks_for_installation, send_alert_webhook_v2, - send_metric_alert_webhook, send_webhooks, workflow_notification, ) @@ -1855,257 +1849,3 @@ def test_regenerate_service_hook_for_installation_with_empty_app_events(self) -> with assume_test_silo_mode(SiloMode.CELL): hook.refresh_from_db() assert hook.events == [] - - -class TestSendMetricAlertWebhook(TestCase): - def setUp(self) -> None: - self.sentry_app = self.create_sentry_app(organization=self.organization) - self.install = self.create_sentry_app_installation( - organization=self.organization, slug=self.sentry_app.slug - ) - self.project = self.create_project(organization=self.organization) - self.alert_id = 42 - self.incident_attachment_json = json.dumps( - { - "metric_alert": { - "alert_rule": { - "triggers": [], - } - }, - "description_text": "Something went wrong", - "description_title": "Test Alert", - "web_url": "http://example.com/alert/1", - } - ) - - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_missing_sentry_app(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: - send_metric_alert_webhook( - sentry_app_id=9999, - new_status=IncidentStatus.CRITICAL.value, - incident_attachment_json=self.incident_attachment_json, - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - ) - - assert not safe_urlopen.called - assert_failure_metric( - mock_record=mock_record, - error_msg=SentryAppWebhookFailureReason.MISSING_SENTRY_APP, - ) - # PREPARE_WEBHOOK (failure) - assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=1 - ) - assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 - ) - - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen") - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_missing_installation(self, mock_record: MagicMock, safe_urlopen: MagicMock) -> None: - other_org = self.create_organization() - uninstalled_app = self.create_sentry_app(organization=other_org) - - send_metric_alert_webhook( - sentry_app_id=uninstalled_app.id, - new_status=IncidentStatus.CRITICAL.value, - incident_attachment_json=self.incident_attachment_json, - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - ) - - assert not safe_urlopen.called - assert_failure_metric( - mock_record=mock_record, - error_msg=SentryAppWebhookFailureReason.MISSING_INSTALLATION, - ) - # APP_CREATE (success) -> PREPARE_WEBHOOK (failure) - assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2 - ) - assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1 - ) - - @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_successful_send_critical_status( - self, - mock_record: MagicMock, - safe_urlopen: MagicMock, - mock_analytics_record: MagicMock, - ) -> None: - notification_uuid = "test-notification-uuid" - send_metric_alert_webhook( - sentry_app_id=self.sentry_app.id, - new_status=IncidentStatus.CRITICAL.value, - incident_attachment_json=self.incident_attachment_json, - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - notification_uuid=notification_uuid, - ) - - assert safe_urlopen.called - ((args, kwargs),) = safe_urlopen.call_args_list - data = json.loads(kwargs["data"]) - assert data["action"] == "critical" - assert data["installation"]["uuid"] == self.install.uuid - assert data["data"]["metric_alert"]["alert_rule"]["triggers"] == [] - - buffer = SentryAppWebhookRequestsBuffer(self.sentry_app) - requests = buffer.get_requests() - assert len(requests) == 1 - assert requests[0]["response_code"] == 200 - assert requests[0]["event_type"] == "metric_alert.critical" - - assert_success_metric(mock_record=mock_record) - # PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success) - assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.STARTED, outcome_count=2 - ) - assert_count_of_metric( - mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=2 - ) - - mock_analytics_record.assert_any_call( - AlertSentEvent( - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - alert_type="metric_alert", - provider="sentry_app", - external_id=str(self.sentry_app.id), - notification_uuid=notification_uuid, - ) - ) - - @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_successful_send_closed_status( - self, - mock_record: MagicMock, - safe_urlopen: MagicMock, - mock_analytics_record: MagicMock, - ) -> None: - send_metric_alert_webhook( - sentry_app_id=self.sentry_app.id, - new_status=IncidentStatus.CLOSED.value, - incident_attachment_json=self.incident_attachment_json, - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - ) - - assert safe_urlopen.called - ((args, kwargs),) = safe_urlopen.call_args_list - data = json.loads(kwargs["data"]) - assert data["action"] == "resolved" - - buffer = SentryAppWebhookRequestsBuffer(self.sentry_app) - requests = buffer.get_requests() - assert len(requests) == 1 - assert requests[0]["event_type"] == "metric_alert.resolved" - - assert_success_metric(mock_record=mock_record) - - @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_ui_component_analytics_recorded_when_sentry_app_action_with_settings( - self, - mock_record: MagicMock, - safe_urlopen: MagicMock, - mock_analytics_record: MagicMock, - ) -> None: - attachment_with_ui_component = json.dumps( - { - "metric_alert": { - "alert_rule": { - "triggers": [ - { - "actions": [ - { - "type": "sentry_app", - "settings": {"key": "value"}, - } - ] - } - ] - } - }, - "description_text": "Something happened", - "description_title": "Test Alert", - "web_url": "http://example.com/alert/1", - } - ) - - send_metric_alert_webhook( - sentry_app_id=self.sentry_app.id, - new_status=IncidentStatus.CRITICAL.value, - incident_attachment_json=attachment_with_ui_component, - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - ) - - mock_analytics_record.assert_any_call( - AlertRuleUiComponentWebhookSentEvent( - organization_id=self.organization.id, - sentry_app_id=self.sentry_app.id, - event="metric_alert.critical", - ) - ) - - @patch("sentry.sentry_apps.tasks.sentry_apps.analytics.record") - @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_ui_component_analytics_not_recorded_without_settings( - self, - mock_record: MagicMock, - safe_urlopen: MagicMock, - mock_analytics_record: MagicMock, - ) -> None: - attachment_without_settings = json.dumps( - { - "metric_alert": { - "alert_rule": { - "triggers": [ - { - "actions": [ - { - "type": "sentry_app", - # no "settings" key - } - ] - } - ] - } - }, - "description_text": "Something happened", - "description_title": "Test Alert", - "web_url": "http://example.com/alert/1", - } - ) - - send_metric_alert_webhook( - sentry_app_id=self.sentry_app.id, - new_status=IncidentStatus.CRITICAL.value, - incident_attachment_json=attachment_without_settings, - organization_id=self.organization.id, - project_id=self.project.id, - alert_id=self.alert_id, - ) - - ui_component_calls = [ - call - for call in mock_analytics_record.call_args_list - if isinstance(call.args[0], AlertRuleUiComponentWebhookSentEvent) - ] - assert len(ui_component_calls) == 0 From aed886ab973105fdf63ad6eeff3a990717e69f97 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 3 Apr 2026 14:27:53 -0700 Subject: [PATCH 24/35] feat(scraps): Add slot component (#112032) Adds new Slot primitive for building detached slots into Scraps and around the UI. The Slot primitive follows a factory pattern which returns the relevant React building blocks to build slots. Slots need to be rendered into DOM elements (constraint due to the use of portals). An example of how a slot may be used is ```tsx import {slot} from '@sentry/scraps/slot' const SlotModule = slot(['Foo'] as const); function Component(props){ return ( {ref => {children} ) } // The children can later render items to the slot via function Child(){ return ... } // The final effect is the equivalent of function Component(props){ return ( {children} ) } ``` --------- Co-authored-by: Claude Sonnet 4 Co-authored-by: Cursor Agent Co-authored-by: Jonas Co-authored-by: Nate Moore Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- static/app/components/core/slot/index.tsx | 1 + static/app/components/core/slot/slot.spec.tsx | 313 ++++++++++++++++++ static/app/components/core/slot/slot.tsx | 260 +++++++++++++++ static/app/views/navigation/topBar.tsx | 49 ++- static/app/views/organizationLayout/index.tsx | 12 +- .../settings/components/settingsLayout.tsx | 25 +- tests/js/sentry-test/reactTestingLibrary.tsx | 17 +- 7 files changed, 645 insertions(+), 32 deletions(-) create mode 100644 static/app/components/core/slot/index.tsx create mode 100644 static/app/components/core/slot/slot.spec.tsx create mode 100644 static/app/components/core/slot/slot.tsx diff --git a/static/app/components/core/slot/index.tsx b/static/app/components/core/slot/index.tsx new file mode 100644 index 000000000000..c270ab4e2a50 --- /dev/null +++ b/static/app/components/core/slot/index.tsx @@ -0,0 +1 @@ +export {slot, withSlots} from './slot'; diff --git a/static/app/components/core/slot/slot.spec.tsx b/static/app/components/core/slot/slot.spec.tsx new file mode 100644 index 000000000000..d23b461d357c --- /dev/null +++ b/static/app/components/core/slot/slot.spec.tsx @@ -0,0 +1,313 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {slot, withSlots} from './'; + +describe('slot', () => { + it('returns a module with Provider, Outlet, and Fallback sub-components', () => { + const SlotModule = slot(['header', 'footer'] as const); + expect(SlotModule).toBeDefined(); + expect(SlotModule.Provider).toBeDefined(); + expect(SlotModule.Outlet).toBeDefined(); + expect(SlotModule.Fallback).toBeDefined(); + }); + + it('renders children in place when no Outlet is registered', () => { + const SlotModule = slot(['header'] as const); + + render( + + + inline content + + + ); + + expect(screen.getByText('inline content')).toBeInTheDocument(); + }); + + it('portals children to the Outlet element', () => { + const SlotModule = slot(['content'] as const); + + render( + + + {props =>
} + + + portaled content + + + ); + + expect(screen.getByTestId('slot-target')).toContainHTML( + 'portaled content' + ); + }); + + it('multiple slot consumers render their children independently', () => { + const SlotModule = slot(['a', 'b'] as const); + + render( + + + slot a content + + + slot b content + + + ); + + expect(screen.getByText('slot a content')).toBeInTheDocument(); + expect(screen.getByText('slot b content')).toBeInTheDocument(); + }); + + it('consumer throws when rendered outside provider', () => { + const SlotModule = slot(['nav'] as const); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + render( + + content + + ) + ).toThrow('SlotContext not found'); + + consoleError.mockRestore(); + }); + + it('Outlet throws when rendered outside provider', () => { + const SlotModule = slot(['aside'] as const); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + render( + {props =>
} + ) + ).toThrow('SlotContext not found'); + + consoleError.mockRestore(); + }); + + it('Outlet renders the element returned by the render prop', () => { + const SlotModule = slot(['sidebar'] as const); + + render( + + + {props =>
} + + + ); + + expect(screen.getByTestId('sidebar-root')).toBeInTheDocument(); + }); + + it('Outlet registers and unregisters the element on mount/unmount', () => { + const SlotModule = slot(['panel'] as const); + + const {unmount} = render( + + + {props =>
} + + + ); + + expect(screen.getByTestId('panel-root')).toBeInTheDocument(); + expect(() => unmount()).not.toThrow(); + }); + + it('provider renders its children', () => { + const SlotModule = slot(['x'] as const); + + render( + + child content + + ); + + expect(screen.getByText('child content')).toBeInTheDocument(); + }); + + it('separate slot() calls create independent slot systems', () => { + const SlotModule1 = slot(['zone'] as const); + const SlotModule2 = slot(['zone'] as const); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + render( + + + content + + + ) + ).toThrow('SlotContext not found'); + + consoleError.mockRestore(); + }); + + describe('Fallback', () => { + it('renders children into Outlet when no consumer is mounted', () => { + const SlotModule = slot(['feedback'] as const); + + render( + + + {props => ( +
+ + default feedback + +
+ )} +
+
+ ); + + expect(screen.getByTestId('feedback-root')).toContainHTML( + 'default feedback' + ); + }); + + it('does not render when a consumer is mounted', () => { + const SlotModule = slot(['feedback'] as const); + + render( + + + {props => ( +
+ + default feedback + +
+ )} +
+ + custom feedback + +
+ ); + + expect(screen.queryByText('default feedback')).not.toBeInTheDocument(); + expect(screen.getByTestId('feedback-root')).toContainHTML( + 'custom feedback' + ); + }); + + it('throws when rendered outside provider', () => { + const SlotModule = slot(['x'] as const); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + render( + + fallback + + ) + ).toThrow('SlotContext not found'); + + consoleError.mockRestore(); + }); + + it('throws when rendered outside Outlet', () => { + const SlotModule = slot(['x'] as const); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + render( + + + fallback + + + ) + ).toThrow('Slot.Fallback must be rendered inside Slot.Outlet'); + + consoleError.mockRestore(); + }); + }); + + describe('Outlet ref stability', () => { + it('ref callback passed to render prop is stable across re-renders', () => { + const SlotModule = slot(['menu'] as const); + const refs: Array> = []; + + function TestComponent() { + return ( + + {props => { + refs.push(props.ref); + return
; + }} + + ); + } + + const {rerender} = render( + + + + ); + + rerender( + + + + ); + + expect(refs[0]).toBe(refs[refs.length - 1]); + }); + }); + + describe('withSlots', () => { + it('attaches a Slot property to a component', () => { + const SlotModule = slot(['header'] as const); + + function MyComponent() { + return
; + } + + const WithSlots = withSlots(MyComponent, SlotModule); + + expect(WithSlots.Slot).toBe(SlotModule); + }); + + it('renders the wrapped component and allows slot injection', () => { + const SlotModule = slot(['title'] as const); + + function MyComponent() { + return ( +
+ + {props => } + +
+ ); + } + + const WithSlots = withSlots(MyComponent, SlotModule); + + render( + + + + injected title + + + ); + + expect(screen.getByTestId('title-outlet')).toContainHTML( + 'injected title' + ); + }); + }); +}); diff --git a/static/app/components/core/slot/slot.tsx b/static/app/components/core/slot/slot.tsx new file mode 100644 index 000000000000..7c0aab1589a2 --- /dev/null +++ b/static/app/components/core/slot/slot.tsx @@ -0,0 +1,260 @@ +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useReducer, +} from 'react'; +import {createPortal} from 'react-dom'; + +type Slot = string; +type SlotValue = {counter: number; element: HTMLElement | null}; + +type SlotReducerState = Partial>; +type SlotReducerAction = + | { + name: T; + type: 'increment counter'; + } + | { + name: T; + type: 'decrement counter'; + } + | { + element: HTMLElement | null; + name: T; + type: 'register'; + } + | { + name: T; + type: 'unregister'; + }; + +type SlotReducer = React.Reducer< + SlotReducerState, + SlotReducerAction +>; + +type SlotContextValue = [ + SlotReducerState, + React.Dispatch>, +]; + +function makeSlotReducer(): SlotReducer { + return function reducer( + state: SlotReducerState, + action: SlotReducerAction + ): SlotReducerState { + switch (action.type) { + case 'increment counter': { + const currentSlot = state[action.name]; + return { + ...state, + [action.name]: { + element: currentSlot?.element ?? null, + counter: (currentSlot?.counter ?? 0) + 1, + }, + }; + } + case 'decrement counter': { + const currentSlot = state[action.name]; + if (!currentSlot) { + return state; + } + return { + ...state, + [action.name]: { + ...currentSlot, + counter: (currentSlot?.counter ?? 0) - 1, + }, + }; + } + case 'register': + return { + ...state, + [action.name]: { + counter: state[action.name]?.counter ?? 0, + element: action.element, + }, + }; + case 'unregister': { + const currentSlot = state[action.name]; + if (!currentSlot) { + return state; + } + return { + ...state, + [action.name]: { + counter: currentSlot?.counter ?? 0, + element: null, + }, + }; + } + default: + return state; + } + }; +} + +interface SlotProviderProps { + children: React.ReactNode; +} + +interface SlotConsumerProps { + children: React.ReactNode; + name: T; +} + +interface SlotOutletProps { + children: (props: {ref: React.RefCallback}) => React.ReactNode; + name: T; +} + +interface SlotFallbackProps { + children: React.ReactNode; +} + +type SlotModule = React.FunctionComponent> & { + Fallback: React.ComponentType; + Outlet: React.ComponentType>; + Provider: React.ComponentType; +}; + +function makeSlotConsumer( + context: React.Context | null> +) { + function SlotConsumer(props: SlotConsumerProps): React.ReactNode { + const ctx = useContext(context); + if (!ctx) { + throw new Error('SlotContext not found'); + } + + const [state, dispatch] = ctx; + const {name} = props; + useLayoutEffect(() => { + dispatch({type: 'increment counter', name}); + return () => dispatch({type: 'decrement counter', name}); + }, [dispatch, name]); + + const element = state[name]?.element; + if (!element) { + // Render in place as a fallback when no target element is registered yet + return props.children; + } + return createPortal(props.children, element); + } + + SlotConsumer.displayName = 'Slot.Consumer'; + return SlotConsumer; +} + +function makeSlotOutlet( + context: React.Context | null>, + outletNameContext: React.Context +) { + function SlotOutlet(props: SlotOutletProps): React.ReactNode { + const ctx = useContext(context); + + if (!ctx) { + throw new Error('SlotContext not found'); + } + + const [, dispatch] = ctx; + const {name} = props; + const ref = useCallback( + (element: HTMLElement | null) => { + if (!element) { + dispatch({type: 'unregister', name}); + return; + } + dispatch({type: 'register', name, element}); + }, + [dispatch, name] + ); + + return ( + + {props.children({ref})} + + ); + } + + SlotOutlet.displayName = 'Slot.Outlet'; + return SlotOutlet; +} + +function makeSlotFallback( + context: React.Context | null>, + outletNameContext: React.Context +) { + function SlotFallback({children}: SlotFallbackProps): React.ReactNode { + const ctx = useContext(context); + if (!ctx) { + throw new Error('SlotContext not found'); + } + + const name = useContext(outletNameContext); + if (name === null) { + throw new Error('Slot.Fallback must be rendered inside Slot.Outlet'); + } + + const [state] = ctx; + if ((state[name]?.counter ?? 0) > 0 || !state[name]?.element) { + return null; + } + + return createPortal(children, state[name].element); + } + + SlotFallback.displayName = 'Slot.Fallback'; + return SlotFallback; +} + +function makeSlotProvider( + context: React.Context | null> +): (props: SlotProviderProps) => React.ReactNode { + const reducer = makeSlotReducer(); + + function SlotProvider({children}: SlotProviderProps) { + const [value, dispatch] = useReducer(reducer, {}); + + const contextValue = useMemo( + () => [value, dispatch] satisfies SlotContextValue, + [value, dispatch] + ); + return {children}; + } + + SlotProvider.displayName = `Slot.Provider`; + return SlotProvider as (props: SlotProviderProps) => React.ReactNode; +} + +export function slot(names: T): SlotModule { + type SlotName = T[number]; + + const SlotContext = createContext | null>(null); + const OutletNameContext = createContext(null); + + const Slot = makeSlotConsumer(SlotContext) as SlotModule; + Slot.Provider = makeSlotProvider(SlotContext); + Slot.Outlet = makeSlotOutlet(SlotContext, OutletNameContext); + Slot.Fallback = makeSlotFallback(SlotContext, OutletNameContext); + + // Keep `names` reference to preserve the const-narrowed type T + void names; + + return Slot; +} + +export function withSlots< + TComponent extends React.ComponentType, + TSlot extends Slot, +>( + Component: TComponent, + slotModule: SlotModule +): TComponent & {Slot: SlotModule} { + const WithSlots = Component as TComponent & {Slot: SlotModule}; + WithSlots.Slot = slotModule; + return WithSlots; +} diff --git a/static/app/views/navigation/topBar.tsx b/static/app/views/navigation/topBar.tsx index 49f2d82012a2..973b2d161818 100644 --- a/static/app/views/navigation/topBar.tsx +++ b/static/app/views/navigation/topBar.tsx @@ -3,6 +3,7 @@ import {useTheme} from '@emotion/react'; import {Button} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; import {SizeProvider} from '@sentry/scraps/sizeContext'; +import {slot, withSlots} from '@sentry/scraps/slot'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {IconSeer} from 'sentry/icons'; @@ -12,10 +13,14 @@ import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFea import {useExplorerPanel} from 'sentry/views/seerExplorer/useExplorerPanel'; import {isSeerExplorerEnabled} from 'sentry/views/seerExplorer/utils'; -import {NAVIGATION_MOBILE_TOPBAR_HEIGHT_WITH_PAGE_FRAME} from './constants'; -import {PRIMARY_HEADER_HEIGHT} from './constants'; +import { + NAVIGATION_MOBILE_TOPBAR_HEIGHT_WITH_PAGE_FRAME, + PRIMARY_HEADER_HEIGHT, +} from './constants'; -export function TopBar() { +const Slot = slot(['title', 'actions', 'feedback'] as const); + +function TopBarContent() { const theme = useTheme(); const organization = useOrganization({allowNull: true}); const hasPageFrame = useHasPageFrameFeature(); @@ -39,29 +44,45 @@ export function TopBar() { position="sticky" borderBottom="primary" top={0} - // Keep the top bar in a cascade slightly below the sidebar panel so that when the sidebar panel - // is in the hover preview state, the top bar does not sit over it. style={{ zIndex: theme.zIndex.sidebarPanel - 1, }} > - {/* @TODO(JonasBadalic): Implement breadcrumbs here */} - - + + {props => } + + + + + {props => } + + {organization && isSeerExplorerEnabled(organization) ? ( ) : null} - - {null} - + + + {props => ( + + {/* If no component registers a feedback button, show the default one */} + + + {null} + + + + )} + ); } + +export const TopBar = withSlots(TopBarContent, Slot); diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index 61486f222bce..0aa207b431b7 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -93,11 +93,13 @@ function AppLayout({organization}: LayoutProps) { {organization && } - - - -