diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 9d559a150da8bd..84999030e7ea21 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -24,6 +24,7 @@ from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse from sentry.hybridcloud.rpc import coerce_id_from from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs +from sentry.issues.action_log import ActionType, publish_action, resolve_action_source from sentry.issues.grouptype import GroupCategory from sentry.issues.ignored import handle_archived_until_escalating, handle_ignored from sentry.issues.merge import MergedGroup, handle_merge @@ -204,6 +205,9 @@ def update_groups( if discard: return handle_discard(request, groups, projects, acting_user) + source = resolve_action_source(request) + actor_id = acting_user.id if acting_user else None + status_details = result.pop("statusDetails", result) status = result.get("status") res_type = None @@ -214,12 +218,26 @@ def update_groups( status=HTTPStatus.BAD_REQUEST, ) + priority_value = PriorityLevel.from_str(result["priority"]) if result["priority"] else None + groups_with_priority_change = [ + g for g in groups if priority_value is not None and g.priority != priority_value + ] handle_priority( priority=result["priority"], group_list=groups, acting_user=acting_user, project_lookup=project_lookup, ) + for group in groups_with_priority_change: + publish_action( + action=ActionType.SET_PRIORITY, + source=source, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + actor_id=actor_id, + metadata={"priority": result["priority"]}, + ) if status in ("resolved", "resolvedInNextRelease"): if any(not group.issue_type.enable_user_status_and_priority_changes for group in groups): return Response( @@ -236,6 +254,8 @@ def update_groups( project_lookup, acting_user, result, + source=source, + actor_id=actor_id, ) except MultipleProjectsError: return Response({"detail": "Cannot set resolved for multiple projects."}, status=400) @@ -247,6 +267,8 @@ def update_groups( project_lookup, status_details, acting_user, + source=source, + actor_id=actor_id, ) return prepare_response( @@ -260,6 +282,8 @@ def update_groups( res_type, request.META.get("HTTP_REFERER", ""), organization, + source=source, + actor_id=actor_id, ) @@ -371,6 +395,8 @@ def handle_resolve_in_release( project_lookup: Mapping[int, Project], acting_user: RpcUser | User | None, result: MutableMapping[str, Any], + source: str | None = None, + actor_id: int | None = None, ) -> tuple[dict[str, Any], int | None]: res_type = None release = None @@ -489,6 +515,17 @@ def handle_resolve_in_release( sender=update_groups, ) + if source is not None: + publish_action( + action=ActionType.RESOLVE, + source=source, + group_id=group.id, + organization_id=projects[0].organization_id, + project_id=group.project_id, + actor_id=actor_id, + metadata={"resolution_type": res_type_str}, + ) + kick_off_status_syncs.apply_async( kwargs={"project_id": group.project_id, "group_id": group.id} ) @@ -719,6 +756,8 @@ def handle_other_status_updates( project_lookup: Mapping[int, Project], status_details: dict[str, Any], acting_user: RpcUser | User | None, + source: str | None = None, + actor_id: int | None = None, ) -> dict[str, Any]: group_ids = [group.id for group in group_list] queryset = Group.objects.filter(id__in=group_ids) @@ -729,6 +768,7 @@ def handle_other_status_updates( new_substatus = infer_substatus(new_status, new_substatus, status_details, group_list) with transaction.atomic(router.db_for_write(Group)): + changed_group_ids = set(queryset.exclude(status=new_status).values_list("id", flat=True)) status_updated = queryset.exclude(status=new_status).update( status=new_status, substatus=new_substatus ) @@ -764,6 +804,22 @@ def handle_other_status_updates( status_details=result.get("statusDetails", {}), sender=update_groups, ) + + if source is not None: + action = ( + ActionType.ARCHIVE if new_status == GroupStatus.IGNORED else ActionType.UNRESOLVE + ) + for group in group_list: + if group.id in changed_group_ids: + publish_action( + action=action, + source=source, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + actor_id=actor_id, + ) + return result @@ -778,6 +834,8 @@ def prepare_response( res_type: int | None, referer: str, organization: Organization | None = None, + source: str | None = None, + actor_id: int | None = None, ) -> Response: # XXX (ahmed): hack to get the activities to work properly on issues page. Not sure of # what performance impact this might have & this possibly should be moved else where @@ -794,6 +852,10 @@ def prepare_response( pass if "assignedTo" in result: + prev_assignees = { + ga.group_id: (ga.user_id, ga.team_id) + for ga in GroupAssignee.objects.filter(group__in=group_list) + } result["assignedTo"] = handle_assigned_to( result["assignedTo"], data.get("assignedBy"), @@ -802,16 +864,79 @@ def prepare_response( project_lookup, acting_user, ) + if source is not None: + action = ActionType.ASSIGN if result["assignedTo"] else ActionType.UNASSIGN + for group in group_list: + had_assignee = group.id in prev_assignees + if action == ActionType.UNASSIGN and not had_assignee: + continue + if action == ActionType.ASSIGN: + cur = ( + GroupAssignee.objects.filter(group=group) + .values_list("user_id", "team_id") + .first() + ) + if cur and prev_assignees.get(group.id) == cur: + continue + publish_action( + action=action, + source=source, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + actor_id=actor_id, + ) handle_has_seen(result.get("hasSeen"), group_list, project_lookup, projects, acting_user) if "isBookmarked" in result: + already_bookmarked: set[int] = set() + if result["isBookmarked"] and source is not None and acting_user is not None: + already_bookmarked = set( + GroupBookmark.objects.filter( + group__in=group_list, + user_id=acting_user.id, + ).values_list("group_id", flat=True) + ) handle_is_bookmarked(result["isBookmarked"], group_list, project_lookup, acting_user) + if source is not None and result["isBookmarked"]: + for group in group_list: + if group.id not in already_bookmarked: + publish_action( + action=ActionType.BOOKMARK, + source=source, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + actor_id=actor_id, + ) if result.get("isSubscribed") in (True, False): + prev_subscriptions: dict[int, bool] = {} + if source is not None and acting_user is not None: + prev_subscriptions = dict( + GroupSubscription.objects.filter( + group__in=group_list, + user_id=acting_user.id, + ).values_list("group_id", "is_active") + ) result["subscriptionDetails"] = handle_is_subscribed( result["isSubscribed"], group_list, project_lookup, acting_user ) + if source is not None: + action = ActionType.SUBSCRIBE if result["isSubscribed"] else ActionType.UNSUBSCRIBE + for group in group_list: + was_active = prev_subscriptions.get(group.id) + if was_active == result["isSubscribed"]: + continue + publish_action( + action=action, + source=source, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + actor_id=actor_id, + ) if "isPublic" in result: result["shareId"] = handle_is_public( @@ -831,9 +956,42 @@ def prepare_response( acting_user, urlparse(referer).path, ) + if source is not None and isinstance(result["merge"], dict): + merged = result["merge"] + primary_id = int(merged["parent"]) + child_ids = [int(c) for c in merged["children"]] + group_by_id = {g.id: g for g in group_list} + primary = group_by_id[primary_id] + publish_action( + action=ActionType.MERGE_FROM_OTHER, + source=source, + group_id=primary_id, + organization_id=primary.project.organization_id, + project_id=primary.project_id, + actor_id=actor_id, + metadata={"counterpart_group_ids": child_ids}, + ) + for child_id in child_ids: + child = group_by_id[child_id] + publish_action( + action=ActionType.MERGE_INTO_OTHER, + source=source, + group_id=child_id, + organization_id=child.project.organization_id, + project_id=child.project_id, + actor_id=actor_id, + metadata={"counterpart_group_id": primary_id}, + ) inbox = result.get("inbox", None) if inbox is not None: + from sentry.models.groupinbox import GroupInbox + + groups_in_inbox: set[int] = set() + if inbox is False: + groups_in_inbox = set( + GroupInbox.objects.filter(group__in=group_list).values_list("group_id", flat=True) + ) result["inbox"] = update_inbox( inbox, group_list, @@ -841,6 +999,17 @@ def prepare_response( acting_user, sender=update_groups, ) + if source is not None and inbox is False: + for group in group_list: + if group.id in groups_in_inbox: + publish_action( + action=ActionType.MARK_REVIEWED, + source=source, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + actor_id=actor_id, + ) # TODO(issues): This type is very fragile since it's fields are updated in quite a few places. # Since this is a public API, we are using assuming a shape of MutateIssueResponse, but this diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py new file mode 100644 index 00000000000000..a4e797cfe55f80 --- /dev/null +++ b/src/sentry/issues/action_log.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import logging +from enum import StrEnum +from typing import Any + +from rest_framework.request import Request + +from sentry.middleware import is_frontend_request + +logger = logging.getLogger(__name__) + +MCP_CLIENT_NAME_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_NAME" +SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" + +_MCP_APPLICATION_ID_NOT_CHECKED = -1 +MCP_APPLICATION_ID: int = _MCP_APPLICATION_ID_NOT_CHECKED + +KNOWN_MCP_CLIENTS: dict[str, str] = { + "claude code": "claude-code", + "claude-code": "claude-code", + "cursor": "cursor", + "copilot": "copilot", + "windsurf": "windsurf", +} + + +class ActionSource(StrEnum): + WEB = "web" + SENTRY_CLI = "sentry-cli" + API = "api" + SYSTEM = "system" + MCP = "mcp" + SEER_EXPLORER = "seer:explorer" + SEER_SLACK = "seer:slack" + SLACK = "slack" + DISCORD = "discord" + MSTEAMS = "msteams" + GITHUB = "github" + GITLAB = "gitlab" + JIRA = "jira" + + +def _get_mcp_application_id() -> int | None: + global MCP_APPLICATION_ID + if MCP_APPLICATION_ID != _MCP_APPLICATION_ID_NOT_CHECKED: + return MCP_APPLICATION_ID if MCP_APPLICATION_ID > 0 else None + + from sentry.models.apiapplication import ApiApplication + + try: + app = ApiApplication.objects.filter(name__icontains="sentry-mcp").first() + if app: + MCP_APPLICATION_ID = app.id + return MCP_APPLICATION_ID + except Exception: + logger.exception("Failed to look up MCP application ID") + + MCP_APPLICATION_ID = 0 + return None + + +def resolve_action_source(request: Request | None) -> str: + if request is None: + return ActionSource.SYSTEM + + # MCP: trust client headers only when token belongs to the MCP OAuth app + application_id = getattr(request.auth, "application_id", None) + mcp_app_id = _get_mcp_application_id() + if mcp_app_id and application_id == mcp_app_id: + client_name = request.META.get(MCP_CLIENT_NAME_HEADER, "") + normalized = client_name.strip().lower() + slug = KNOWN_MCP_CLIENTS.get(normalized) + if slug: + return f"mcp:{slug}" + return ActionSource.MCP + + # Seer: detect via X-Seer-Referrer header (trust but label) + seer_referrer = request.META.get(SEER_REFERRER_HEADER, "") + if seer_referrer: + if "slack" in seer_referrer.lower(): + return ActionSource.SEER_SLACK + return ActionSource.SEER_EXPLORER + + # Seer RPC: authenticated via HMAC shared secret + from sentry.seer.endpoints.seer_rpc import SeerRpcSignatureAuthentication + + if isinstance( + getattr(request, "successful_authenticator", None), SeerRpcSignatureAuthentication + ): + return ActionSource.SEER_EXPLORER + + if is_frontend_request(request): + return ActionSource.WEB + + user_agent = request.META.get("HTTP_USER_AGENT", "") + if user_agent.startswith("sentry-cli/"): + return ActionSource.SENTRY_CLI + + return ActionSource.API + + +class ActorType(StrEnum): + USER = "user" + SYSTEM = "system" + + +class ActionType(StrEnum): + VIEW = "view" + RESOLVE = "resolve" + UNRESOLVE = "unresolve" + ARCHIVE = "archive" + ASSIGN = "assign" + UNASSIGN = "unassign" + SET_PRIORITY = "set_priority" + MERGE_INTO_OTHER = "merge_into_other" + MERGE_FROM_OTHER = "merge_from_other" + DELETE = "delete" + BOOKMARK = "bookmark" + COMMENT = "comment" + SUBSCRIBE = "subscribe" + UNSUBSCRIBE = "unsubscribe" + MARK_REVIEWED = "mark_reviewed" + TRIGGER_AUTOFIX = "trigger_autofix" + + +def publish_action( + *, + action: ActionType, + source: str, + group_id: int, + organization_id: int, + project_id: int, + actor_id: int | None = None, + metadata: dict[str, Any] | None = None, + idempotency_key: str | None = None, +) -> None: + """ + No-op publisher — emits a structured log line and a metric counter. + Will be swapped for the real Action Log publisher when available. + """ + actor_type = ActorType.USER if actor_id is not None else ActorType.SYSTEM + logger.info( + "issue.action_log", + extra={ + "action": action, + "source": source, + "group_id": group_id, + "organization_id": organization_id, + "project_id": project_id, + "actor_type": actor_type, + "actor_id": actor_id, + "metadata": metadata or {}, + "idempotency_key": idempotency_key, + }, + ) diff --git a/src/sentry/issues/endpoints/group_details.py b/src/sentry/issues/endpoints/group_details.py index 9dbc8dbcc7b9a6..f628ffd7ec26af 100644 --- a/src/sentry/issues/endpoints/group_details.py +++ b/src/sentry/issues/endpoints/group_details.py @@ -26,6 +26,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.integrations.api.serializers.models.external_issue import ExternalIssueSerializer from sentry.integrations.models.external_issue import ExternalIssue +from sentry.issues.action_log import ActionType, publish_action, resolve_action_source from sentry.issues.constants import get_issue_tsdb_group_model from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.issues.escalating.escalating_group_forecast import EscalatingGroupForecast @@ -293,6 +294,17 @@ def get(self, request: Request, group: Group) -> Response: data.update({"participants": participants}) + publish_action( + action=ActionType.VIEW, + source=resolve_action_source(request), + group_id=group.id, + organization_id=organization.id, + project_id=group.project_id, + actor_id=request.user.id + if getattr(request.user, "is_authenticated", False) + else None, + ) + metrics.incr( "group.get.http_response", sample_rate=1.0, diff --git a/src/sentry/seer/assisted_query/discover_tools.py b/src/sentry/seer/assisted_query/discover_tools.py index cf4ee6352e016e..6a55acad05b46c 100644 --- a/src/sentry/seer/assisted_query/discover_tools.py +++ b/src/sentry/seer/assisted_query/discover_tools.py @@ -2,7 +2,7 @@ import re from typing import Any -from sentry.api import client +from sentry.api.client import ApiClient from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.models.apikey import ApiKey from sentry.models.organization import Organization @@ -10,6 +10,7 @@ from sentry.snuba.referrer import Referrer logger = logging.getLogger(__name__) +client = ApiClient() # Filter keys we think are useful to *always* return to the query agent. diff --git a/src/sentry/seer/assisted_query/issues_tools.py b/src/sentry/seer/assisted_query/issues_tools.py index bcd3411334cf2b..095a000ffd53ec 100644 --- a/src/sentry/seer/assisted_query/issues_tools.py +++ b/src/sentry/seer/assisted_query/issues_tools.py @@ -2,7 +2,7 @@ import re from typing import Any -from sentry.api import client +from sentry.api.client import ApiClient, ApiError from sentry.issues.grouptype import registry as group_type_registry from sentry.models.apikey import ApiKey from sentry.models.organization import Organization @@ -17,6 +17,7 @@ from sentry.users.services.user.service import user_service logger = logging.getLogger(__name__) +client = ApiClient() IS_VALUES = [ "resolved", @@ -718,7 +719,7 @@ def execute_issues_query( params=params, ) return resp.data - except client.ApiError as e: + except ApiError as e: if e.status_code == 400: error_detail = e.body.get("detail") if isinstance(e.body, dict) else None return {"error": str(error_detail) if error_detail is not None else str(e.body)} diff --git a/src/sentry/seer/assisted_query/metrics_tools.py b/src/sentry/seer/assisted_query/metrics_tools.py index 2fa1b0faee0790..f38f3440ff2ff6 100644 --- a/src/sentry/seer/assisted_query/metrics_tools.py +++ b/src/sentry/seer/assisted_query/metrics_tools.py @@ -1,13 +1,14 @@ import logging from typing import Any, TypedDict -from sentry.api import client +from sentry.api.client import ApiClient, ApiError from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.models.apikey import ApiKey from sentry.models.organization import Organization from sentry.snuba.referrer import Referrer logger = logging.getLogger(__name__) +client = ApiClient() API_KEY_SCOPES = ["org:read", "project:read", "event:read"] @@ -116,7 +117,7 @@ def get_metric_metadata( path=f"/organizations/{organization.slug}/events/", params=params, ) - except client.ApiError as e: + except ApiError as e: # Surface status + body prefix in log extras so prod flakes are debuggable # without a new deploy. Keep the return `error` code stable for callers. logger.exception( diff --git a/src/sentry/seer/assisted_query/traces_tools.py b/src/sentry/seer/assisted_query/traces_tools.py index 2f14bac3b773ee..d011a3c4b4c54a 100644 --- a/src/sentry/seer/assisted_query/traces_tools.py +++ b/src/sentry/seer/assisted_query/traces_tools.py @@ -1,12 +1,13 @@ import logging from typing import Any -from sentry.api import client +from sentry.api.client import ApiClient from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.models.apikey import ApiKey from sentry.models.organization import Organization logger = logging.getLogger(__name__) +client = ApiClient() API_KEY_SCOPES = ["org:read", "project:read", "event:read"] diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index f2cba8afd9b664..f61ab3af5733ec 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -24,6 +24,7 @@ from sentry.apidocs.parameters import GlobalParams, IssueParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE +from sentry.issues.action_log import ActionType, publish_action, resolve_action_source from sentry.issues.endpoints.bases.group import GroupAiEndpoint from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig @@ -181,6 +182,23 @@ def post(self, request: Request, group: Group) -> Response: The process runs asynchronously, and you can get the state using the GET endpoint. """ + response = self._post_inner(request, group) + + if response.status_code == status.HTTP_202_ACCEPTED: + publish_action( + action=ActionType.TRIGGER_AUTOFIX, + source=resolve_action_source(request), + group_id=group.id, + organization_id=group.organization.id, + project_id=group.project_id, + actor_id=request.user.id + if getattr(request.user, "is_authenticated", False) + else None, + ) + + return response + + def _post_inner(self, request: Request, group: Group) -> Response: serializer = ExplorerAutofixRequestSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py new file mode 100644 index 00000000000000..aabf50bdc49e41 --- /dev/null +++ b/tests/sentry/issues/test_action_log.py @@ -0,0 +1,368 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +from sentry.issues.action_log import ( + ActionType, + publish_action, + resolve_action_source, +) +from sentry.models.group import GroupStatus +from sentry.seer.endpoints.seer_rpc import SeerRpcSignatureAuthentication +from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase +from sentry.types.group import GroupSubStatus, PriorityLevel + + +def _make_request( + *, + application_id: int | None = None, + meta: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + auth: Any = None, + successful_authenticator: Any = None, +) -> MagicMock: + request = MagicMock() + request.META = meta or {} + request.COOKIES = cookies or {} + + if auth is not None: + request.auth = auth + else: + token = MagicMock() + token.application_id = application_id + request.auth = token + + request.successful_authenticator = successful_authenticator + return request + + +class TestResolveActionSource(TestCase): + def test_no_request_returns_system(self) -> None: + assert resolve_action_source(None) == "system" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_known_client_claude_code(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=42, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code"}, + ) + assert resolve_action_source(request) == "mcp:claude-code" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_known_client_cursor(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=42, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "cursor"}, + ) + assert resolve_action_source(request) == "mcp:cursor" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_unknown_client(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=42, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "some-new-editor"}, + ) + assert resolve_action_source(request) == "mcp" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_no_client_header(self, mock_get_id: MagicMock) -> None: + request = _make_request(application_id=42) + assert resolve_action_source(request) == "mcp" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_header_ignored_when_wrong_application(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=999, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code"}, + ) + assert resolve_action_source(request) == "api" + + def test_seer_referrer_explorer(self) -> None: + request = _make_request( + meta={"HTTP_X_SEER_REFERRER": "seer-explorer"}, + ) + assert resolve_action_source(request) == "seer:explorer" + + def test_seer_referrer_slack(self) -> None: + request = _make_request( + meta={"HTTP_X_SEER_REFERRER": "seer-slack-bot"}, + ) + assert resolve_action_source(request) == "seer:slack" + + def test_seer_rpc_authenticator(self) -> None: + authenticator = MagicMock(spec=SeerRpcSignatureAuthentication) + request = _make_request(successful_authenticator=authenticator) + assert resolve_action_source(request) == "seer:explorer" + + def test_frontend_request(self) -> None: + request = _make_request(cookies={"session": "abc"}) + request.auth = None + assert resolve_action_source(request) == "web" + + def test_sentry_cli(self) -> None: + request = _make_request( + meta={"HTTP_USER_AGENT": "sentry-cli/2.30.0"}, + ) + assert resolve_action_source(request) == "sentry-cli" + + def test_generic_api_fallback(self) -> None: + request = _make_request( + meta={"HTTP_USER_AGENT": "python-requests/2.31.0"}, + ) + assert resolve_action_source(request) == "api" + + def test_empty_request_falls_through_to_api(self) -> None: + request = _make_request() + assert resolve_action_source(request) == "api" + + +class TestPublishAction(TestCase): + def test_emits_structured_log(self) -> None: + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.RESOLVE, + source="mcp:claude-code", + group_id=1, + organization_id=2, + project_id=3, + actor_id=4, + ) + assert len(logs.records) == 1 + record = logs.records[0] + extra = record.__dict__ + assert record.message == "issue.action_log" + assert extra["action"] == "resolve" + assert extra["source"] == "mcp:claude-code" + assert extra["group_id"] == 1 + assert extra["organization_id"] == 2 + assert extra["project_id"] == 3 + assert extra["actor_id"] == 4 + + def test_emits_without_optional_fields(self) -> None: + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.VIEW, + source="web", + group_id=10, + organization_id=20, + project_id=30, + ) + extra = logs.records[0].__dict__ + assert extra["actor_id"] is None + assert extra["metadata"] == {} + + def test_actor_type_derived_from_actor_id(self) -> None: + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.RESOLVE, + source="web", + group_id=1, + organization_id=2, + project_id=3, + actor_id=99, + ) + assert logs.records[0].__dict__["actor_type"] == "user" + + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.RESOLVE, + source="system", + group_id=1, + organization_id=2, + project_id=3, + ) + assert logs.records[0].__dict__["actor_type"] == "system" + + +PUBLISH_UPDATE = "sentry.api.helpers.group_index.update.publish_action" +PUBLISH_DETAILS = "sentry.issues.endpoints.group_details.publish_action" + + +class TestActionLogIntegration(APITestCase, SnubaTestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.group = self.create_group( + status=GroupStatus.UNRESOLVED, + substatus=GroupSubStatus.ONGOING, + priority=PriorityLevel.MEDIUM, + ) + self.url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group.id}/" + + @patch(PUBLISH_UPDATE) + def test_resolve_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"status": "resolved"}, format="json") + assert response.status_code == 200 + mock_publish.assert_called_once() + kwargs = mock_publish.call_args.kwargs + assert kwargs["action"] == ActionType.RESOLVE + assert kwargs["group_id"] == self.group.id + assert kwargs["actor_id"] == self.user.id + + @patch(PUBLISH_UPDATE) + def test_resolve_already_resolved_skips_action(self, mock_publish: MagicMock) -> None: + self.group.update(status=GroupStatus.RESOLVED, substatus=None) + response = self.client.put(self.url, data={"status": "resolved"}, format="json") + assert response.status_code == 200 + mock_publish.assert_not_called() + + @patch(PUBLISH_UPDATE) + def test_archive_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put( + self.url, + data={"status": "ignored", "substatus": "archived_until_escalating"}, + format="json", + ) + assert response.status_code == 200 + mock_publish.assert_called_once() + assert mock_publish.call_args.kwargs["action"] == ActionType.ARCHIVE + + @patch(PUBLISH_UPDATE) + def test_priority_change_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"priority": "high"}, format="json") + assert response.status_code == 200 + mock_publish.assert_called_once() + kwargs = mock_publish.call_args.kwargs + assert kwargs["action"] == ActionType.SET_PRIORITY + assert kwargs["metadata"] == {"priority": "high"} + + @patch(PUBLISH_UPDATE) + def test_priority_same_value_skips_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"priority": "medium"}, format="json") + assert response.status_code == 200 + mock_publish.assert_not_called() + + @patch(PUBLISH_UPDATE) + def test_assign_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put( + self.url, + data={"assignedTo": f"user:{self.user.id}"}, + format="json", + ) + assert response.status_code == 200 + calls = [c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.ASSIGN] + assert len(calls) == 1 + assert calls[0].kwargs["group_id"] == self.group.id + + @patch(PUBLISH_UPDATE) + def test_assign_same_user_skips_action(self, mock_publish: MagicMock) -> None: + self.client.put( + self.url, + data={"assignedTo": f"user:{self.user.id}"}, + format="json", + ) + mock_publish.reset_mock() + self.client.put( + self.url, + data={"assignedTo": f"user:{self.user.id}"}, + format="json", + ) + assign_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.ASSIGN + ] + assert len(assign_calls) == 0 + + @patch(PUBLISH_UPDATE) + def test_unassign_without_assignee_skips_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"assignedTo": ""}, format="json") + assert response.status_code == 200 + unassign_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.UNASSIGN + ] + assert len(unassign_calls) == 0 + + @patch(PUBLISH_DETAILS) + def test_view_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.get(self.url, format="json") + assert response.status_code == 200 + mock_publish.assert_called_once() + kwargs = mock_publish.call_args.kwargs + assert kwargs["action"] == ActionType.VIEW + assert kwargs["group_id"] == self.group.id + + @patch(PUBLISH_UPDATE) + def test_mark_reviewed_only_for_groups_in_inbox(self, mock_publish: MagicMock) -> None: + from sentry.models.groupinbox import GroupInbox, GroupInboxReason, add_group_to_inbox + + group_in_inbox = self.create_group( + status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.NEW + ) + group_not_in_inbox = self.create_group( + status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING + ) + add_group_to_inbox(group_in_inbox, GroupInboxReason.NEW) + assert GroupInbox.objects.filter(group=group_in_inbox).exists() + assert not GroupInbox.objects.filter(group=group_not_in_inbox).exists() + + url = f"/api/0/organizations/{self.organization.slug}/issues/?id={group_in_inbox.id}&id={group_not_in_inbox.id}" + response = self.client.put( + url, + data={"inbox": False}, + format="json", + ) + assert response.status_code == 200 + reviewed_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.MARK_REVIEWED + ] + assert len(reviewed_calls) == 1 + assert reviewed_calls[0].kwargs["group_id"] == group_in_inbox.id + + @patch(PUBLISH_UPDATE) + def test_archive_already_archived_skips_action(self, mock_publish: MagicMock) -> None: + self.group.update(status=GroupStatus.IGNORED, substatus=GroupSubStatus.UNTIL_ESCALATING) + response = self.client.put( + self.url, + data={"status": "ignored", "substatus": "archived_until_escalating"}, + format="json", + ) + assert response.status_code == 200 + archive_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.ARCHIVE + ] + assert len(archive_calls) == 0 + + @patch(PUBLISH_UPDATE) + def test_unresolve_already_unresolved_skips_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"status": "unresolved"}, format="json") + assert response.status_code == 200 + unresolve_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.UNRESOLVE + ] + assert len(unresolve_calls) == 0 + + @patch(PUBLISH_UPDATE) + def test_unassign_with_existing_assignee_emits_action(self, mock_publish: MagicMock) -> None: + self.client.put( + self.url, + data={"assignedTo": f"user:{self.user.id}"}, + format="json", + ) + mock_publish.reset_mock() + response = self.client.put(self.url, data={"assignedTo": ""}, format="json") + assert response.status_code == 200 + unassign_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.UNASSIGN + ] + assert len(unassign_calls) == 1 + assert unassign_calls[0].kwargs["group_id"] == self.group.id + + @patch(PUBLISH_UPDATE) + def test_bookmark_already_bookmarked_skips_action(self, mock_publish: MagicMock) -> None: + self.client.put(self.url, data={"isBookmarked": True}, format="json") + mock_publish.reset_mock() + response = self.client.put(self.url, data={"isBookmarked": True}, format="json") + assert response.status_code == 200 + bookmark_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.BOOKMARK + ] + assert len(bookmark_calls) == 0 + + @patch(PUBLISH_UPDATE) + def test_subscribe_already_subscribed_skips_action(self, mock_publish: MagicMock) -> None: + self.client.put(self.url, data={"isSubscribed": True}, format="json") + mock_publish.reset_mock() + response = self.client.put(self.url, data={"isSubscribed": True}, format="json") + assert response.status_code == 200 + subscribe_calls = [ + c for c in mock_publish.call_args_list if c.kwargs["action"] == ActionType.SUBSCRIBE + ] + assert len(subscribe_calls) == 0