Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/sentry/integrations/mixins/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sentry.eventstore.models import GroupEvent
from sentry.integrations.base import IntegrationInstallation
from sentry.integrations.models.external_issue import ExternalIssue
from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.integrations.services.integration import integration_service
from sentry.integrations.tasks.sync_status_inbound import (
sync_status_inbound as sync_status_inbound_task,
Expand Down Expand Up @@ -62,7 +63,7 @@ def from_resolve_unresolve(


class IssueBasicIntegration(IntegrationInstallation, ABC):
def should_sync(self, attribute):
def should_sync(self, attribute, sync_source: AssignmentSource | None = None):
return False

def get_group_title(self, group, event, **kwargs):
Expand Down Expand Up @@ -378,10 +379,17 @@ class IssueSyncIntegration(IssueBasicIntegration, ABC):
outbound_assignee_key: ClassVar[str | None] = None
inbound_assignee_key: ClassVar[str | None] = None

def should_sync(self, attribute: str) -> bool:
def should_sync(self, attribute: str, sync_source: AssignmentSource | None = None) -> bool:
key = getattr(self, f"{attribute}_key", None)
if key is None or self.org_integration is None:
return False

# Check that the assignment source isn't this same integration in order to
# prevent sync-cycles from occurring. This should still allow other
# integrations to propagate changes outward.
if sync_source and sync_source.integration_id == self.org_integration.integration_id:
return False

value: bool = self.org_integration.config.get(key, False)
return value

Expand All @@ -400,7 +408,14 @@ def sync_assignee_outbound(
raise NotImplementedError

@abstractmethod
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):
def sync_status_outbound(
self,
external_issue,
is_resolved,
project_id,
assignment_source: AssignmentSource | None = None,
**kwargs,
):
"""
Propagate a sentry issue's status to a linked issue's status.
"""
Expand Down
35 changes: 35 additions & 0 deletions src/sentry/integrations/services/assignment_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from dataclasses import asdict, dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any

from django.utils import timezone

if TYPE_CHECKING:
from sentry.integrations.models import Integration
from sentry.integrations.services.integration import RpcIntegration


@dataclass(frozen=True)
class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()
Comment on lines +14 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Fix mutable default using field(default_factory=...).

Using timezone.now() as a default value evaluates once at class definition time, meaning all AssignmentSource instances will share the same timestamp instead of capturing when each was actually created.

🔎 Proposed fix
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
 from django.utils import timezone
 
 if TYPE_CHECKING:
     from sentry.integrations.models import Integration
     from sentry.integrations.services.integration import RpcIntegration
 
 
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@dataclass(frozen=True)
class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from django.utils import timezone
if TYPE_CHECKING:
from sentry.integrations.models import Integration
from sentry.integrations.services.integration import RpcIntegration
@dataclass(frozen=True)
class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = field(default_factory=timezone.now)
🧰 Tools
🪛 Ruff (0.14.10)

18-18: Do not perform function call timezone.now in dataclass defaults

(RUF009)

🤖 Prompt for AI Agents
In @src/sentry/integrations/services/assignment_source.py around lines 14 - 18,
The queued default is evaluated at import time causing all AssignmentSource
instances to share one timestamp; change the queued field to use a per-instance
factory: replace queued: datetime = timezone.now() with queued: datetime =
field(default_factory=timezone.now) and add the dataclasses.field import (from
dataclasses import dataclass, field) so each AssignmentSource gets the current
time when constructed.


@classmethod
def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:
return AssignmentSource(
source_name=integration.name,
integration_id=integration.id,
)

def to_dict(self) -> dict[str, Any]:
return asdict(self)
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the assignment_source.py file to understand the dataclass
head -50 src/sentry/integrations/services/assignment_source.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 220


🏁 Script executed:

# Search for to_dict() usage in the codebase
rg -n 'to_dict\(\)' --type=py | head -20

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 2364


🏁 Script executed:

# Check task/sync files that might use assignment_source
rg -l 'assignment_source' --type=py | grep -E '(task|sync|queue)'

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 175


🏁 Script executed:

# Look for datetime fields in assignment_source
rg -n 'datetime' src/sentry/integrations/services/assignment_source.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 154


🏁 Script executed:

# Check sync.py to see how assignment_source is used
cat -n src/sentry/integrations/utils/sync.py | head -100

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 4178


🏁 Script executed:

# Check sync_assignee_outbound.py
cat -n src/sentry/integrations/tasks/sync_assignee_outbound.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 3043


🏁 Script executed:

# Find where to_dict() is called on assignment_source before passing to task
rg -n -B 5 -A 5 'sync_assignee_outbound' src/sentry/integrations/ --type=py | head -80

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 6403


🏁 Script executed:

# Check the from_dict and to_dict implementations in assignment_source
cat -n src/sentry/integrations/services/assignment_source.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 1307


🏁 Script executed:

# Verify Celery's JSON serialization behavior - check if there's custom encoder
rg -n 'json_encoder\|JSONEncoder\|default=' src/sentry/ --type=py | grep -E '(celery|task)' | head -10

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check if there are any successful test cases with assignment_source in Celery context
rg -n 'assignment_source.*to_dict\|sync_assignee_outbound' tests/ --type=py | head -20

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check Celery configuration in settings
rg -n 'CELERY\|kombu\|json' src/sentry/conf/ --type=py | grep -i 'json\|serial' | head -15

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Look for tests that actually call sync_assignee_outbound with assignment_source
rg -n -B 3 -A 3 'sync_assignee_outbound\|assignment_source' tests/sentry/integrations/ --type=py | head -100

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check if there's any isoformat or string conversion in from_dict or to_dict elsewhere
rg -n 'isoformat\|strftime\|fromisoformat\|fromtimestamp' src/sentry/integrations/ --type=py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check how other dataclasses handle to_dict with datetime fields
rg -n '@dataclass' src/sentry/ --type=py -A 10 | grep -B 5 -A 5 'datetime' | head -80

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 4851


🏁 Script executed:

# Look for custom serialization patterns in services
rg -n 'def to_dict\|def from_dict' src/sentry/services/ --type=py -A 3 | head -100

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check the actual Celery setup to understand serialization
fd 'celery' src/sentry/conf/ --type py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 251


🏁 Script executed:

# Check if datetime is actually causing issues in task parameters
rg -n 'apply_async.*dict' src/sentry/integrations/ --type=py -B 3 -A 1

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check the sync.py file more carefully around the apply_async call
sed -n '120,150p' src/sentry/integrations/utils/sync.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 916


🏁 Script executed:

# Look for Celery configuration regarding datetime serialization
rg -n 'datetime\|timestamp' src/sentry/conf/settings/ --type=py | grep -i celery

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 147


🏁 Script executed:

# Check if there are any actual test failures or usage patterns for assignment_source in tasks
find tests -name '*.py' -exec grep -l 'assignment_source' {} \;

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 184


🏁 Script executed:

# See if there's a custom JSON encoder used for Celery
rg -n 'task_serializer\|result_serializer\|accept_content\|timezone' src/sentry/conf/ --type=py | head -20

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check the assignment_source tests
cat -n tests/sentry/integrations/services/test_assignment_source.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 1488


🏁 Script executed:

# Check the groupassignee tests
cat -n tests/sentry/models/test_groupassignee.py | head -100

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 4931


🏁 Script executed:

# Find Celery configuration
find src/sentry -name 'celery.py' -o -name '*celery*.py' | head -5

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 249


🏁 Script executed:

# Look for where Celery kombu settings are configured
rg -n 'CELERY\|kombu_parser\|accept_content' src/sentry --type=py | head -20

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check main celery configuration
cat -n src/sentry/celery.py | head -100

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 4151


🏁 Script executed:

# Check celery types
cat -n src/sentry/conf/types/celery.py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 934


🏁 Script executed:

# Check if there's a custom datetime JSON encoder
rg -n 'JSONEncoder\|json.dumps\|json.loads' src/sentry/utils/celery.py -A 3

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Test if the issue actually occurs by looking at how apply_async handles datetime
rg -n 'apply_async' src/sentry/integrations/ --type=py -B 2 -A 2 | grep -A 5 'assignment_source'

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check if there are integration tests that actually run sync_assignee_outbound with assignment_source
rg -n 'sync_assignee_outbound\|sync_group_assignee_outbound' tests/sentry/integrations/ --type=py | head -30

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check Celery broker and serializer configuration
rg -n 'CELERY_TASK_SERIALIZER\|CELERY_ACCEPT_CONTENT\|broker\|serializer' src/sentry --type=py | head -20

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Verify that datetime in to_dict is problematic by checking if there's a custom encoder
python3 << 'EOF'
import json
from datetime import datetime

# Test if datetime is JSON serializable by default
try:
    test_dict = {"queued": datetime.now()}
    json.dumps(test_dict)
    print("datetime is JSON serializable")
except TypeError as e:
    print(f"datetime is NOT JSON serializable: {e}")
EOF

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 168


🏁 Script executed:

# Check what Python's json module does with datetime
python3 << 'EOF'
import json
from datetime import datetime

# Simulate what asdict does with datetime
test_dict = {
    "source_name": "test",
    "integration_id": 123,
    "queued": datetime.now()
}

# Try to serialize
try:
    json.dumps(test_dict)
    print("SUCCESS: datetime serialized")
except TypeError as e:
    print(f"FAILURE: {e}")
EOF

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 139


🏁 Script executed:

# Check if there's a custom Celery setup with JSON encoder
find src/sentry -name '*.py' -exec grep -l 'json_encoder\|JSONEncoder\|default=' {} \; | xargs grep -l 'celery\|task' | head -10

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 581


🏁 Script executed:

# Look at settings or configuration for Celery serializer
rg -n 'accept_content\|task_serializer\|result_serializer' src/sentry --type=py | head -20

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


🏁 Script executed:

# Check if there are any workarounds or custom serialization for datetime in the codebase
rg -n 'isoformat\|timestamp\|strptime\|fromisoformat' src/sentry/integrations --type=py

Repository: AI-Code-Review-Evals/coderabbit-sentry

Length of output: 64


Convert datetime to ISO format string in to_dict() for Celery serialization.

The to_dict() method includes the datetime object directly via asdict(), but Python's json module cannot serialize datetime objects. When sync_assignee_outbound.apply_async() passes this dict to Celery, task serialization will fail. Convert the queued field to an ISO format string in to_dict() and update from_dict() to deserialize it back to a datetime object.

🤖 Prompt for AI Agents
In @src/sentry/integrations/services/assignment_source.py around lines 27 - 28,
Update the AssignmentSource dataclass serialization: modify to_dict() to convert
the queued datetime field to an ISO 8601 string (e.g., queued.isoformat())
instead of returning a raw datetime via asdict(), and update from_dict() to
parse that ISO string back into a datetime (e.g., datetime.fromisoformat()) when
reconstructing the object; ensure the change covers the same key name "queued"
so sync_assignee_outbound.apply_async() receives a JSON-serializable dict.


@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):
return None
19 changes: 16 additions & 3 deletions src/sentry/integrations/tasks/sync_assignee_outbound.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from typing import Any

from sentry import analytics, features
from sentry.integrations.models.external_issue import ExternalIssue
from sentry.integrations.models.integration import Integration
from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.integrations.services.integration import integration_service
from sentry.models.organization import Organization
from sentry.silo.base import SiloMode
Expand All @@ -24,7 +27,12 @@
Organization.DoesNotExist,
)
)
def sync_assignee_outbound(external_issue_id: int, user_id: int | None, assign: bool) -> None:
def sync_assignee_outbound(
external_issue_id: int,
user_id: int | None,
assign: bool,
assignment_source_dict: dict[str, Any] | None = None,
) -> None:
# Sync Sentry assignee to an external issue.
external_issue = ExternalIssue.objects.get(id=external_issue_id)

Expand All @@ -42,10 +50,15 @@ def sync_assignee_outbound(external_issue_id: int, user_id: int | None, assign:
):
return

if installation.should_sync("outbound_assignee"):
parsed_assignment_source = (
AssignmentSource.from_dict(assignment_source_dict) if assignment_source_dict else None
)
if installation.should_sync("outbound_assignee", parsed_assignment_source):
# Assume unassign if None.
user = user_service.get_user(user_id) if user_id else None
installation.sync_assignee_outbound(external_issue, user, assign=assign)
installation.sync_assignee_outbound(
external_issue, user, assign=assign, assignment_source=parsed_assignment_source
)
analytics.record(
"integration.issue.assignee.synced",
provider=integration.provider,
Expand Down
29 changes: 25 additions & 4 deletions src/sentry/integrations/utils/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING

from sentry import features
from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.integrations.services.integration import integration_service
from sentry.integrations.tasks.sync_assignee_outbound import sync_assignee_outbound
from sentry.models.group import Group
Expand Down Expand Up @@ -92,7 +93,11 @@ def sync_group_assignee_inbound(

if not assign:
for group in affected_groups:
GroupAssignee.objects.deassign(group)
GroupAssignee.objects.deassign(
group,
assignment_source=AssignmentSource.from_integration(integration),
)

return affected_groups

users = user_service.get_many_by_email(emails=[email], is_verified=True)
Expand All @@ -104,14 +109,23 @@ def sync_group_assignee_inbound(
user_id = get_user_id(projects_by_user, group)
user = users_by_id.get(user_id)
if user:
GroupAssignee.objects.assign(group, user)
GroupAssignee.objects.assign(
group,
user,
assignment_source=AssignmentSource.from_integration(integration),
)
groups_assigned.append(group)
else:
logger.info("assignee-not-found-inbound", extra=log_context)
return groups_assigned


def sync_group_assignee_outbound(group: Group, user_id: int | None, assign: bool = True) -> None:
def sync_group_assignee_outbound(
group: Group,
user_id: int | None,
assign: bool = True,
assignment_source: AssignmentSource | None = None,
) -> None:
from sentry.models.grouplink import GroupLink

external_issue_ids = GroupLink.objects.filter(
Expand All @@ -120,5 +134,12 @@ def sync_group_assignee_outbound(group: Group, user_id: int | None, assign: bool

for external_issue_id in external_issue_ids:
sync_assignee_outbound.apply_async(
kwargs={"external_issue_id": external_issue_id, "user_id": user_id, "assign": assign}
kwargs={
"external_issue_id": external_issue_id,
"user_id": user_id,
"assign": assign,
"assignment_source_dict": assignment_source.to_dict()
if assignment_source
else None,
}
)
11 changes: 9 additions & 2 deletions src/sentry/models/groupassignee.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
from sentry.db.models.manager.base import BaseManager
from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.models.grouphistory import GroupHistoryStatus, record_group_history
from sentry.models.groupowner import GroupOwner
from sentry.models.groupsubscription import GroupSubscription
Expand Down Expand Up @@ -134,6 +135,7 @@ def assign(
create_only: bool = False,
extra: dict[str, str] | None = None,
force_autoassign: bool = False,
assignment_source: AssignmentSource | None = None,
):
from sentry.integrations.utils import sync_group_assignee_outbound
from sentry.models.activity import Activity
Expand Down Expand Up @@ -187,7 +189,9 @@ def assign(
if assignee_type == "user" and features.has(
"organizations:integrations-issue-sync", group.organization, actor=acting_user
):
sync_group_assignee_outbound(group, assigned_to.id, assign=True)
sync_group_assignee_outbound(
group, assigned_to.id, assign=True, assignment_source=assignment_source
)

if not created: # aka re-assignment
self.remove_old_assignees(group, assignee, assigned_to_id, assignee_type)
Expand All @@ -200,6 +204,7 @@ def deassign(
acting_user: User | RpcUser | None = None,
assigned_to: Team | RpcUser | None = None,
extra: dict[str, str] | None = None,
assignment_source: AssignmentSource | None = None,
) -> None:
from sentry.integrations.utils import sync_group_assignee_outbound
from sentry.models.activity import Activity
Expand Down Expand Up @@ -230,7 +235,9 @@ def deassign(
if features.has(
"organizations:integrations-issue-sync", group.organization, actor=acting_user
):
sync_group_assignee_outbound(group, None, assign=False)
sync_group_assignee_outbound(
group, None, assign=False, assignment_source=assignment_source
)

issue_unassigned.send_robust(
project=group.project, group=group, user=acting_user, sender=self.__class__
Expand Down
38 changes: 38 additions & 0 deletions tests/sentry/integrations/services/test_assignment_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from typing import Any

from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.testutils.cases import TestCase


class TestAssignmentSource(TestCase):
def test_from_dict_empty_array(self):
data: dict[str, Any] = {}
result = AssignmentSource.from_dict(data)
assert result is None

def test_from_dict_inalid_data(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix typo in test method name.

The method name contains a typo: test_from_dict_inalid_data should be test_from_dict_invalid_data.

🔎 Proposed fix
-    def test_from_dict_inalid_data(self):
+    def test_from_dict_invalid_data(self):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_from_dict_inalid_data(self):
def test_from_dict_invalid_data(self):
🤖 Prompt for AI Agents
In @tests/sentry/integrations/services/test_assignment_source.py at line 13,
Rename the misnamed test method test_from_dict_inalid_data to
test_from_dict_invalid_data in the test class so the name reflects "invalid"
(update the def declaration and any references to it, e.g., test discovery or
direct calls), then run the test suite to ensure no other references or typos
remain; ensure the new name uses the exact spelling test_from_dict_invalid_data.

data = {
"foo": "bar",
}

result = AssignmentSource.from_dict(data)
assert result is None

def test_from_dict_valid_data(self):
data = {"source_name": "foo-source", "integration_id": 123}

result = AssignmentSource.from_dict(data)
assert result is not None
assert result.source_name == "foo-source"
assert result.integration_id == 123

def test_to_dict(self):
source = AssignmentSource(
source_name="foo-source",
integration_id=123,
)

result = source.to_dict()
assert result.get("queued") is not None
assert result.get("source_name") == "foo-source"
assert result.get("integration_id") == 123
74 changes: 71 additions & 3 deletions tests/sentry/models/test_groupassignee.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from sentry.integrations.example.integration import ExampleIntegration
from sentry.integrations.models.external_issue import ExternalIssue
from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.integrations.utils import sync_group_assignee_inbound
from sentry.models.activity import Activity
from sentry.models.groupassignee import GroupAssignee
Expand Down Expand Up @@ -148,12 +149,77 @@ def test_assignee_sync_outbound_assign(self, mock_sync_assignee_outbound):

with self.feature({"organizations:integrations-issue-sync": True}):
with self.tasks():
GroupAssignee.objects.assign(self.group, self.user)
GroupAssignee.objects.assign(
self.group,
self.user,
)

mock_sync_assignee_outbound.assert_called_with(
external_issue, user_service.get_user(self.user.id), assign=True
external_issue,
user_service.get_user(self.user.id),
assign=True,
assignment_source=None,
)

assert GroupAssignee.objects.filter(
project=self.group.project,
group=self.group,
user_id=self.user.id,
team__isnull=True,
).exists()

activity = Activity.objects.get(
project=self.group.project, group=self.group, type=ActivityType.ASSIGNED.value
)

assert activity.data["assignee"] == str(self.user.id)
assert activity.data["assigneeEmail"] == self.user.email
assert activity.data["assigneeType"] == "user"

@mock.patch.object(ExampleIntegration, "sync_assignee_outbound")
def test_assignee_sync_outbound_assign_with_matching_source_integration(
self, mock_sync_assignee_outbound
):
group = self.group
integration = self.create_integration(
organization=group.organization,
external_id="123456",
provider="example",
oi_params={
"config": {
"sync_comments": True,
"sync_status_outbound": True,
"sync_status_inbound": True,
"sync_assignee_outbound": True,
"sync_assignee_inbound": True,
}
},
)

external_issue = ExternalIssue.objects.create(
organization_id=group.organization.id, integration_id=integration.id, key="APP-123"
)

GroupLink.objects.create(
group_id=group.id,
project_id=group.project_id,
linked_type=GroupLink.LinkedType.issue,
linked_id=external_issue.id,
relationship=GroupLink.Relationship.references,
)

with self.feature({"organizations:integrations-issue-sync": True}):
with self.tasks():
# Assert that we don't perform an outbound assignment if
# the source of the assignment is the same target integration
GroupAssignee.objects.assign(
self.group,
self.user,
assignment_source=AssignmentSource.from_integration(integration),
)

mock_sync_assignee_outbound.assert_not_called()

assert GroupAssignee.objects.filter(
project=self.group.project,
group=self.group,
Expand Down Expand Up @@ -205,7 +271,9 @@ def test_assignee_sync_outbound_unassign(self, mock_sync_assignee_outbound):
with self.feature({"organizations:integrations-issue-sync": True}):
with self.tasks():
GroupAssignee.objects.deassign(self.group)
mock_sync_assignee_outbound.assert_called_with(external_issue, None, assign=False)
mock_sync_assignee_outbound.assert_called_with(
external_issue, None, assign=False, assignment_source=None
)

assert not GroupAssignee.objects.filter(
project=self.group.project,
Expand Down