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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from dataclasses import dataclass, field
from datetime import datetime
import time

def now():
    return datetime.utcnow()

`@dataclass`(frozen=True)
class Broken:
    queued: datetime = now()

`@dataclass`(frozen=True)
class Fixed:
    queued: datetime = field(default_factory=now)

a = Broken()
time.sleep(1)
b = Broken()
print("broken timestamps equal:", a.queued == b.queued, a.queued, b.queued)

c = Fixed()
time.sleep(1)
d = Fixed()
print("fixed timestamps equal:", c.queued == d.queued, c.queued, d.queued)
PY

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 272


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/sentry/integrations/services/assignment_source.py"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi
nl -ba "$FILE" | sed -n '1,120p'

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 147


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/sentry/integrations/services/assignment_source.py"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

# Print with line numbers around the reported location (14-18)
python3 - <<'PY'
path = "src/sentry/integrations/services/assignment_source.py"
start, end = 1, 80
with open(path, "r", encoding="utf-8") as f:
    for i, line in enumerate(f, start=1):
        if start <= i <= end:
            print(f"{i:4d}:{line.rstrip()}")
        if i > end:
            break
PY

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 1260


Use field(default_factory=timezone.now) for AssignmentSource.queued.

In src/sentry/integrations/services/assignment_source.py (around lines 14-18), queued: datetime = timezone.now() is evaluated once at class definition time, so all instances created without an explicit queued share the same timestamp. Use default_factory instead.

Suggested fix
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
...
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
🧰 Tools
🪛 Ruff (0.15.15)

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

(RUF009)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sentry/integrations/services/assignment_source.py` around lines 14 - 18,
The dataclass AssignmentSource defines queued: datetime = timezone.now() which
evaluates once at import time causing all instances to share the same timestamp;
change queued to use dataclasses.field with a default_factory that calls
timezone.now (i.e. queued: datetime = field(default_factory=timezone.now)) so
each new AssignmentSource gets a fresh timestamp, and import field from
dataclasses if not already imported.


@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)

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):
return None
Comment on lines +27 to +35

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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from dataclasses import dataclass

`@dataclass`(frozen=True)
class AssignmentSource:
    source_name: str
    integration_id: int

bad = AssignmentSource(**{"source_name": "jira", "integration_id": "123"})
print("runtime type:", type(bad.integration_id).__name__)
print("matches int 123:", bad.integration_id == 123)
PY

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 145


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Read the actual AssignmentSource definition + from_dict implementation
FILE="src/sentry/integrations/services/assignment_source.py"
if [ -f "$FILE" ]; then
  echo "=== $FILE ==="
  nl -ba "$FILE" | sed -n '1,200p'
else
  echo "Missing file: $FILE" >&2
  exit 1
fi

# 2) Find where IssueSyncIntegration.should_sync compares integration_id
# (Search for IssueSyncIntegration and should_sync)
echo "=== Search IssueSyncIntegration ==="
rg -n "class IssueSyncIntegration|def should_sync" -S src/sentry || true

# 3) Find comparisons involving integration_id in sync-related code
echo "=== Search integration_id comparisons ==="
rg -n "integration_id" -S src/sentry/integrations || true

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/sentry/integrations/services/assignment_source.py"
echo "=== $FILE (first 200 lines with numbers) ==="
if [ -f "$FILE" ]; then
  cat -n "$FILE" | sed -n '1,200p'
else
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "=== rg: class IssueSyncIntegration / def should_sync ==="
rg -n "class IssueSyncIntegration|def should_sync" -S src/sentry || true

echo "=== rg: integration_id comparisons in integrations/services ==="
rg -n "integration_id" -S src/sentry/integrations/services || true

echo "=== rg: integration_id comparisons in integrations ==="
rg -n "integration_id" -S src/sentry/integrations | head -n 200 || true

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 35789


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== rg: AssignmentSource.from_dict usage ==="
rg -n "AssignmentSource\.from_dict|\.from_dict\(" -S src/sentry/integrations/mixins/issues.py src/sentry || true

echo "=== show IssueSyncIntegration.should_sync implementation ==="
# Print the relevant region around should_sync and any uses of sync_source/integration_id comparisons
rg -n "class IssueSyncIntegration|def should_sync" -n src/sentry/integrations/mixins/issues.py
sed -n '330,470p' src/sentry/integrations/mixins/issues.py

echo "=== also inspect earlier should_sync (mixin base) ==="
sed -n '1,120p' src/sentry/integrations/mixins/issues.py

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 11356


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Show sync_assignee_outbound.py around AssignmentSource.from_dict ==="
sed -n '1,140p' src/sentry/integrations/tasks/sync_assignee_outbound.py

echo "=== rg assignment_source_dict ==="
rg -n "assignment_source_dict" -S src/sentry/integrations || true

echo "=== rg: should_sync( and sync_source= ==="
rg -n "should_sync\\(|sync_source=" -S src/sentry/integrations/mixins/issues.py src/sentry/integrations || true

echo "=== rg: from_dict(assignment_source"  # narrow search
rg -n "AssignmentSource\\.from_dict\\(" -S src/sentry/integrations || true

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 4775


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Show src/sentry/integrations/utils/sync.py around assignment_source_dict ==="
sed -n '1,220p' src/sentry/integrations/utils/sync.py

echo "=== Search other task usage of AssignmentSource.from_dict(assignment_source_dict) ==="
rg -n "AssignmentSource\\.from_dict\\(assignment_source_dict\\)" -S src/sentry/integrations/tasks || true

echo "=== Inspect sync_status_outbound task for AssignmentSource.from_dict usage ==="
sed -n '1,140p' src/sentry/integrations/tasks/sync_status_outbound.py

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 7529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== rg: AssignmentSource.from_dict( ==="
rg -n "AssignmentSource\.from_dict\(" -S src/sentry || true

echo "=== rg: AssignmentSource\.to_dict\("  # where dict payloads are created
rg -n "AssignmentSource\.to_dict\(" -S src/sentry || true

echo "=== rg: assignment_source_dict building ==="
rg -n "assignment_source_dict" -S src/sentry/integrations || true

echo "=== rg: from_integration( ==="
rg -n "AssignmentSource\.from_integration" -S src/sentry/integrations || true

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 1038


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search Celery/task serializer configuration ==="
rg -n "task_serializer|accept_content|serializer|json_serializer|pickle|dumps\\(|loads\\(" -S src/sentry | head -n 200

echo "=== Search for custom serializer that might stringify ints/datetimes ==="
rg -n "orjson|ujson|msgpack|pickle|kombu|celery" -S src/sentry | head -n 200

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 40162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== rg Celery TASK_SERIALIZER / ACCEPT_CONTENT ==="
rg -n "CELERY_(TASK_SERIALIZER|ACCEPT_CONTENT|RESULT_SERIALIZER|SERIALIZER)|task_serializer|accept_content" -S . | head -n 200 || true

echo "=== rg sentry/celery config ==="
rg -n "from celery|Celery\\(" -S src/sentry/celery src/sentry | head -n 200 || true

echo "=== list likely settings files ==="
fd -t f -e py -e toml -e ini -e yml -e yaml -e cfg -a "settings*.py" . 2>/dev/null || true

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 4189


Downgrade severity: current code path doesn’t stringify integration_id

AssignmentSource.from_dict() constructs the dataclass via cls(**input_dict) with no runtime type validation, and IssueSyncIntegration.should_sync() uses direct int equality. However, along the only call path (AssignmentSource.to_dict()sync_assignee_outbound.apply_async(... assignment_source_dict=...)AssignmentSource.from_dict()), Celery task kwargs are serialized with CELERY_TASK_SERIALIZER="pickle" and only "pickle" is accepted, so integration_id should remain an int (not a "123" string). The proposed “string bypasses the loop guard” scenario doesn’t appear to occur here; at most, this is a defensive hardening opportunity for future/untrusted callers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sentry/integrations/services/assignment_source.py` around lines 27 - 35,
AssignmentSource.from_dict currently blindly unpacks input_dict into the
dataclass which can leave integration_id as a non-int for future/untrusted
callers; change from_dict in AssignmentSource to defensively validate/coerce
integration_id (e.g., if "integration_id" in input_dict then attempt
int(input_dict["integration_id"]) or return None on failure) before calling
cls(**input_dict) so IssueSyncIntegration.should_sync (which expects int
equality) always sees an int; keep to_dict unchanged but ensure from_dict
normalizes types and returns None on invalid values.

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):
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