From 2a454b08b4fa80ed14417275590d9f472f8e26b2 Mon Sep 17 00:00:00 2001 From: sehr-m <58871345+sehr-m@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:55:15 -0700 Subject: [PATCH] fix(seer): Set viewer context at missing assignment boundaries --- src/sentry/issues/ingest.py | 12 +++++++- src/sentry/models/groupassignee.py | 5 ++- src/sentry/receivers/releases.py | 31 ++++++++++++++++--- tests/sentry/issues/test_ingest.py | 22 +++++++++++++- tests/sentry/models/test_groupassignee.py | 25 +++++++++++++++ tests/sentry/receivers/test_releases.py | 37 +++++++++++++++++++++-- 6 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/sentry/issues/ingest.py b/src/sentry/issues/ingest.py index 1707dcb55ef0..6193b36ed650 100644 --- a/src/sentry/issues/ingest.py +++ b/src/sentry/issues/ingest.py @@ -36,6 +36,7 @@ from sentry.utils import json, metrics, redis from sentry.utils.strings import truncatechars from sentry.utils.tag_normalization import normalized_sdk_tag_from_event +from sentry.viewer_context import ActorType, ViewerContext, viewer_context_scope from sentry.workflow_engine.models import IncidentGroupOpenPeriod from sentry.workflow_engine.processors.detector import ( associate_new_group_with_detector, @@ -302,7 +303,16 @@ def save_issue_from_occurrence( try: # Since this calls hybrid cloud it has to be run outside the transaction assignee = occurrence.assignee.resolve() - with action_context_scope(source=ActionSource.SYSTEM, actor=SYSTEM_ACTOR): + with ( + viewer_context_scope( + ViewerContext( + organization_id=project.organization_id, + project_id=project.id, + actor_type=ActorType.SYSTEM, + ) + ), + action_context_scope(source=ActionSource.SYSTEM, actor=SYSTEM_ACTOR), + ): GroupAssignee.objects.assign(group, assignee, create_only=True) except Exception: logger.exception("Failed to process assignment for occurrence") diff --git a/src/sentry/models/groupassignee.py b/src/sentry/models/groupassignee.py index ff5a910252f4..0c96867a9f31 100644 --- a/src/sentry/models/groupassignee.py +++ b/src/sentry/models/groupassignee.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextvars import logging from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypedDict @@ -197,8 +198,10 @@ def assign( affected = True if affected: + assignment_context = contextvars.copy_context() transaction.on_commit( - lambda: issue_assigned.send_robust( + lambda: assignment_context.run( + issue_assigned.send_robust, project=group.project, group=group, user=acting_user, diff --git a/src/sentry/receivers/releases.py b/src/sentry/receivers/releases.py index 5a92c69d7295..b1f3062d0ad9 100644 --- a/src/sentry/receivers/releases.py +++ b/src/sentry/receivers/releases.py @@ -45,6 +45,7 @@ from sentry.users.services.user import RpcUser from sentry.users.services.user_option import get_option_from_list, user_option_service from sentry.utils.cache import cache +from sentry.viewer_context import ActorType, ViewerContext, viewer_context_scope logger = logging.getLogger(__name__) @@ -219,8 +220,19 @@ def resolved_in_commit(instance: Commit, created, **kwargs): if acting_user: if self_assign_issue == "1" and not group.assignee_set.exists(): - with action_context_scope( - source=ActionSource.SYSTEM, actor=GroupActionActor.user(acting_user.id) + with ( + viewer_context_scope( + ViewerContext( + organization_id=group.project.organization_id, + project_id=group.project_id, + user_id=acting_user.id, + actor_type=ActorType.USER, + ) + ), + action_context_scope( + source=ActionSource.SYSTEM, + actor=GroupActionActor.user(acting_user.id), + ), ): GroupAssignee.objects.assign( group=group, assigned_to=acting_user, acting_user=acting_user @@ -315,8 +327,19 @@ def resolved_in_pull_request(instance: PullRequest, created, **kwargs): linked_id=instance.id, ) if acting_user: - with action_context_scope( - source=ActionSource.SYSTEM, actor=GroupActionActor.user(acting_user.id) + with ( + viewer_context_scope( + ViewerContext( + organization_id=group.project.organization_id, + project_id=group.project_id, + user_id=acting_user.id, + actor_type=ActorType.USER, + ) + ), + action_context_scope( + source=ActionSource.SYSTEM, + actor=GroupActionActor.user(acting_user.id), + ), ): GroupAssignee.objects.assign( group=group, assigned_to=acting_user, acting_user=acting_user diff --git a/tests/sentry/issues/test_ingest.py b/tests/sentry/issues/test_ingest.py index 0c15c7f8a317..44ac0d53d6f2 100644 --- a/tests/sentry/issues/test_ingest.py +++ b/tests/sentry/issues/test_ingest.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from hashlib import md5 +from typing import Any from unittest import mock from unittest.mock import patch @@ -50,6 +51,7 @@ from sentry.utils import json from sentry.utils.samples import load_data from sentry.utils.snuba import raw_query +from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context from sentry.workflow_engine.models import DataCondition, DataConditionGroup, DataPacket from sentry.workflow_engine.models.alertrule_detector import AlertRuleDetector from sentry.workflow_engine.models.detector_group import DetectorGroup @@ -161,11 +163,29 @@ def test_new_group_with_priority(self) -> None: def test_new_group_with_user_assignee(self) -> None: event = self.store_event(data={}, project_id=self.project.id) occurrence = self.build_occurrence(event_id=event.event_id, assignee=f"user:{self.user.id}") - _, group_info = save_issue_occurrence(occurrence.to_dict(), event) + captured_contexts: list[ViewerContext | None] = [] + assign = GroupAssignee.objects.assign + + def capture_context(*args: Any, **kwargs: Any) -> Any: + captured_contexts.append(get_viewer_context()) + return assign(*args, **kwargs) + + with patch( + "sentry.issues.ingest.GroupAssignee.objects.assign", side_effect=capture_context + ): + _, group_info = save_issue_occurrence(occurrence.to_dict(), event) + assert group_info is not None assert group_info.group.priority == PriorityLevel.LOW assignee = GroupAssignee.objects.get(group=group_info.group) assert assignee.user_id == self.user.id + assert captured_contexts == [ + ViewerContext( + organization_id=self.organization.id, + project_id=self.project.id, + actor_type=ActorType.SYSTEM, + ) + ] def test_new_group_with_team_assignee(self) -> None: event = self.store_event(data={}, project_id=self.project.id) diff --git a/tests/sentry/models/test_groupassignee.py b/tests/sentry/models/test_groupassignee.py index 08abd9d9bbee..539c958e3462 100644 --- a/tests/sentry/models/test_groupassignee.py +++ b/tests/sentry/models/test_groupassignee.py @@ -15,11 +15,36 @@ from sentry.testutils.skips import requires_snuba from sentry.types.activity import ActivityType from sentry.users.services.user.service import user_service +from sentry.viewer_context import ActorType as ViewerActorType +from sentry.viewer_context import ViewerContext, get_viewer_context, viewer_context_scope pytestmark = requires_snuba class GroupAssigneeTestCase(TestCase): + def test_assign_preserves_viewer_context_until_on_commit(self) -> None: + expected_context = ViewerContext( + organization_id=self.organization.id, + project_id=self.project.id, + user_id=self.user.id, + actor_type=ViewerActorType.USER, + ) + captured_contexts: list[ViewerContext | None] = [] + + with mock.patch("sentry.models.groupassignee.issue_assigned.send_robust") as send_robust: + send_robust.side_effect = lambda **kwargs: captured_contexts.append( + get_viewer_context() + ) + with self.capture_on_commit_callbacks() as callbacks: + with viewer_context_scope(expected_context): + GroupAssignee.objects.assign(self.group, self.user) + + assert get_viewer_context() is None + for callback in callbacks: + callback() + + assert captured_contexts == [expected_context] + def test_constraints(self) -> None: # Can't both be assigned with pytest.raises(AssertionError): diff --git a/tests/sentry/receivers/test_releases.py b/tests/sentry/receivers/test_releases.py index 029505c3cc86..fcd125c285e3 100644 --- a/tests/sentry/receivers/test_releases.py +++ b/tests/sentry/receivers/test_releases.py @@ -40,6 +40,7 @@ from sentry.types.activity import ActivityType from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail +from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context class InvalidateReleaseCacheTest(TestCase): @@ -326,7 +327,15 @@ def test_matching_author_with_assignment(self) -> None: ) author.preload_users() - with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + captured_contexts: list[ViewerContext | None] = [] + with ( + self.assertLogs("sentry.issues.action_log", level="INFO") as logs, + patch("sentry.models.groupassignee.issue_assigned.send_robust") as send_robust, + self.capture_on_commit_callbacks(execute=True), + ): + send_robust.side_effect = lambda **kwargs: captured_contexts.append( + get_viewer_context() + ) commit = Commit.objects.create( key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), organization_id=group.organization.id, @@ -338,6 +347,14 @@ def test_matching_author_with_assignment(self) -> None: self.assertLinkedFromCommitDeferred(group, commit) assert GroupAssignee.objects.filter(group=group, user_id=user.id).exists() + assert captured_contexts == [ + ViewerContext( + organization_id=group.organization.id, + project_id=group.project_id, + user_id=user.id, + actor_type=ActorType.USER, + ) + ] # The self-assign is attributed to the commit author, not logged as a system action. assign_records = [r for r in logs.records if r.__dict__.get("action") == "assign"] @@ -452,7 +469,15 @@ def test_matching_external_actor_sets_activity_user(self) -> None: ) author = self._create_pull_request_author("newdev", group.organization.id) - pull_request = self._create_resolving_pull_request(group, repo, author) + captured_contexts: list[ViewerContext | None] = [] + with ( + patch("sentry.models.groupassignee.issue_assigned.send_robust") as send_robust, + self.capture_on_commit_callbacks(execute=True), + ): + send_robust.side_effect = lambda **kwargs: captured_contexts.append( + get_viewer_context() + ) + pull_request = self._create_resolving_pull_request(group, repo, author) activity = Activity.objects.get( group=group, @@ -461,6 +486,14 @@ def test_matching_external_actor_sets_activity_user(self) -> None: assert activity.user_id == user.id assert activity.data == {"pull_request": pull_request.id} assert GroupAssignee.objects.filter(group=group, user_id=user.id).exists() + assert captured_contexts == [ + ViewerContext( + organization_id=group.organization.id, + project_id=group.project_id, + user_id=user.id, + actor_type=ActorType.USER, + ) + ] @receivers_raise_on_send() def test_author_from_different_organization_does_not_set_activity_user(self) -> None: