Skip to content

Commit 28f20ec

Browse files
committed
feat(issue-platform): Allow assignee to be passed with the occurrence
We want to allow issue platform customers to specify who should be assigned to the issues created from their occurrences. This assignee will only be used when creating a new issue, it won't change the assignee of an existing issue. Assignees can be team or user. We already have a format for passing this via the API, so I'm using this here too. The Actor format (separate from the `Actor` model which is being removed) allows us to specify the assignee in a structured way. The format is defined here: https://github.com/getsentry/sentry/blob/ef66c637e6a69de97552a1fd6b200e16939afa94/src/sentry/utils/actor.py#L41-L47
1 parent 92e8a0c commit 28f20ec

File tree

5 files changed

+74
-1
lines changed

5 files changed

+74
-1
lines changed

src/sentry/issues/issue_occurrence.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import logging
45
from collections.abc import Mapping, Sequence
56
from dataclasses import dataclass
67
from datetime import datetime
@@ -10,6 +11,7 @@
1011

1112
from sentry import nodestore
1213
from sentry.issues.grouptype import GroupType, get_group_type_by_type_id
14+
from sentry.utils.actor import ActorTuple
1315
from sentry.utils.dates import parse_timestamp
1416

1517
DEFAULT_LEVEL = "info"
@@ -36,6 +38,9 @@ class IssueOccurrenceData(TypedDict):
3638
level: str | None
3739
culprit: str | None
3840
initial_issue_priority: NotRequired[int | None]
41+
# Who to assign the issue to when creating a new issue. Has no effect on existing issues.
42+
# In the format of an Actor identifier, as defined in `ActorTuple.from_actor_identifier`
43+
assignee: NotRequired[str | None]
3944

4045

4146
@dataclass(frozen=True)
@@ -88,6 +93,7 @@ class IssueOccurrence:
8893
level: str
8994
culprit: str
9095
initial_issue_priority: int | None = None
96+
assignee: ActorTuple | None = None
9197

9298
def __post_init__(self) -> None:
9399
if not is_aware(self.detection_time):
@@ -111,17 +117,32 @@ def to_dict(
111117
"level": self.level,
112118
"culprit": self.culprit,
113119
"initial_issue_priority": self.initial_issue_priority,
120+
"assignee": self.assignee.identifier if self.assignee else None,
114121
}
115122

116123
@classmethod
117124
def from_dict(cls, data: IssueOccurrenceData) -> IssueOccurrence:
125+
from sentry.api.serializers.rest_framework import ValidationError
126+
118127
# Backwards compatibility - we used to not require this field, so set a default when `None`
119128
level = data.get("level")
120129
if not level:
121130
level = DEFAULT_LEVEL
122131
culprit = data.get("culprit")
123132
if not culprit:
124133
culprit = ""
134+
135+
assignee = None
136+
try:
137+
# Note that this can cause IO, but in practice this will happen only the first time that
138+
# the occurrence is sent to the issue platform. We then translate to the id and store
139+
# that, so subsequent fetches won't cause IO.
140+
assignee = ActorTuple.from_actor_identifier(data.get("assignee"))
141+
except ValidationError:
142+
logging.exception("Failed to parse assignee actor identifier")
143+
except Exception:
144+
# We never want this to cause parsing an occurrence to fail
145+
logging.exception("Unexpected error parsing assignee")
125146
return cls(
126147
data["id"],
127148
data["project_id"],
@@ -141,6 +162,7 @@ def from_dict(cls, data: IssueOccurrenceData) -> IssueOccurrence:
141162
level,
142163
culprit,
143164
data.get("initial_issue_priority"),
165+
assignee,
144166
)
145167

146168
@property

src/sentry/issues/occurrence_consumer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def _get_kwargs(payload: Mapping[str, Any]) -> Mapping[str, Any]:
154154
"type": payload["type"],
155155
"detection_time": payload["detection_time"],
156156
"level": payload.get("level", DEFAULT_LEVEL),
157+
"assignee": payload.get("assignee"),
157158
}
158159

159160
process_occurrence_data(occurrence_data)

src/sentry/utils/actor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def from_actor_identifier(cls, actor_identifier: int | str | None) -> ActorTuple
4747
"maisey@dogsrule.com" -> look up User by primary email
4848
"""
4949

50-
if actor_identifier is None:
50+
if not actor_identifier:
5151
return None
5252

5353
# If we have an integer, fall back to assuming it's a User

tests/sentry/issues/test_issue_occurrence.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from sentry.issues.issue_occurrence import DEFAULT_LEVEL, IssueEvidence, IssueOccurrence
2+
from sentry.models.team import Team
3+
from sentry.models.user import User
24
from sentry.testutils.cases import TestCase
5+
from sentry.utils.actor import ActorTuple
36
from tests.sentry.issues.test_utils import OccurrenceTestMixin
47

58

@@ -16,6 +19,35 @@ def test_level_default(self) -> None:
1619
occurrence = IssueOccurrence.from_dict(occurrence_data)
1720
assert occurrence.level == DEFAULT_LEVEL
1821

22+
def test_assignee(self) -> None:
23+
occurrence_data = self.build_occurrence_data()
24+
occurrence_data["assignee"] = f"user:{self.user.id}"
25+
occurrence = IssueOccurrence.from_dict(occurrence_data)
26+
assert occurrence.assignee == ActorTuple(self.user.id, User)
27+
occurrence_data["assignee"] = f"{self.user.id}"
28+
occurrence = IssueOccurrence.from_dict(occurrence_data)
29+
assert occurrence.assignee == ActorTuple(self.user.id, User)
30+
occurrence_data["assignee"] = f"{self.user.email}"
31+
occurrence = IssueOccurrence.from_dict(occurrence_data)
32+
assert occurrence.assignee == ActorTuple(self.user.id, User)
33+
occurrence_data["assignee"] = f"{self.user.username}"
34+
occurrence = IssueOccurrence.from_dict(occurrence_data)
35+
assert occurrence.assignee == ActorTuple(self.user.id, User)
36+
occurrence_data["assignee"] = f"team:{self.team.id}"
37+
occurrence = IssueOccurrence.from_dict(occurrence_data)
38+
assert occurrence.assignee == ActorTuple(self.team.id, Team)
39+
40+
def test_assignee_none(self) -> None:
41+
occurrence_data = self.build_occurrence_data()
42+
occurrence = IssueOccurrence.from_dict(occurrence_data)
43+
assert occurrence.assignee is None
44+
occurrence_data["assignee"] = None
45+
occurrence = IssueOccurrence.from_dict(occurrence_data)
46+
assert occurrence.assignee is None
47+
occurrence_data["assignee"] = ""
48+
occurrence = IssueOccurrence.from_dict(occurrence_data)
49+
assert occurrence.assignee is None
50+
1951

2052
class IssueOccurrenceSaveAndFetchTest(OccurrenceTestMixin, TestCase):
2153
def test(self) -> None:

tests/sentry/issues/test_occurrence_consumer.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,3 +458,21 @@ def test_priority_overrides_defaults(self) -> None:
458458
message["initial_issue_priority"] = PriorityLevel.HIGH
459459
kwargs = _get_kwargs(message)
460460
assert kwargs["occurrence_data"]["initial_issue_priority"] == PriorityLevel.HIGH
461+
462+
def test_assignee(self) -> None:
463+
message = deepcopy(get_test_message(self.project.id))
464+
message["assignee"] = f"user:{self.user.id}"
465+
kwargs = _get_kwargs(message)
466+
assert kwargs["occurrence_data"]["assignee"] == f"user:{self.user.id}"
467+
468+
def test_assignee_none(self) -> None:
469+
kwargs = _get_kwargs(deepcopy(get_test_message(self.project.id)))
470+
assert kwargs["occurrence_data"]["assignee"] is None
471+
message = deepcopy(get_test_message(self.project.id))
472+
message["assignee"] = None
473+
kwargs = _get_kwargs(message)
474+
assert kwargs["occurrence_data"]["assignee"] is None
475+
message = deepcopy(get_test_message(self.project.id))
476+
message["assignee"] = ""
477+
kwargs = _get_kwargs(message)
478+
assert kwargs["occurrence_data"]["assignee"] == ""

0 commit comments

Comments
 (0)