Skip to content

feat(ecosystem): Implement cross-system issue synchronization#4

Open
Frawatson wants to merge 1 commit into
ecosystem-sync-integration-beforefrom
ecosystem-sync-integration-after
Open

feat(ecosystem): Implement cross-system issue synchronization#4
Frawatson wants to merge 1 commit into
ecosystem-sync-integration-beforefrom
ecosystem-sync-integration-after

Conversation

@Frawatson

Copy link
Copy Markdown
Owner

Benchmark test PR #7 - Replicating Greptile benchmark for Grapple PR evaluation.

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR implements a cycle-prevention mechanism for cross-system issue synchronization by introducing an AssignmentSource dataclass that tracks which integration triggered an assignment, allowing the sync logic to skip re-propagating changes back to the originating integration.

Changes: 7 files (+212 / -15 lines)

  • 🟢 2 files added: src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
  • 🟡 5 files modified: src/sentry/integrations/mixins/issues.py, src/sentry/integrations/tasks/sync_assignee_outbound.py, src/sentry/integrations/utils/sync.py, src/sentry/models/groupassignee.py, tests/sentry/models/test_groupassignee.py

Resolves: #7 (GitHub OAuth Security Enhancement)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 7 files"/]
  f1["🟡 issues.py +18/-3"]
  pr_0 --> f1
  f2["🟢 assignment_source.py +35/-0"]
  pr_0 --> f2
  f3["🟡 sync_assignee_outbound.py +16/-3"]
  pr_0 --> f3
  f4["🟡 sync.py +25/-4"]
  pr_0 --> f4
  f5["🟡 groupassignee.py +9/-2"]
  pr_0 --> f5
  f6["🟢 test_assignment_source.py +38/-0"]
  pr_0 --> f6
  f7["🟡 test_groupassignee.py +71/-3"]
  pr_0 --> f7
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

2 critical issues found — please address before merging.

Severity Count
🔴 Critical 2
🟠 Major 4
🟡 Minor 5
Review details
  • Reviewed at: 2026-04-07T18:57:55.155Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 74.9s
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Dataclass Default Value (confidence: 100%)

queued: datetime = timezone.now() is evaluated once at class definition time, not per-instance. Every AssignmentSource instance that doesn't explicitly provide queued will share the same datetime value — the time the module was first imported. This is a well-known Python dataclass pitfall explicitly called out in the intent specification's edge cases.

Evidence:

  • The intent specification explicitly warns: 'The queued field uses timezone.now() as a default value at class definition time (dataclass field default), not at instantiation time'
  • Python dataclass defaults are evaluated once at class definition, not per-instance creation
  • The queued field should use field(default_factory=timezone.now) to get a fresh timestamp per instance
  • Since the class is frozen=True, the stale default cannot even be corrected after construction

Agent: logic

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Serialization Round-Trip (confidence: 100%)

The queued field is a datetime object. When to_dict() is called, asdict() preserves it as a datetime. This dict is then passed as a Celery task kwarg and serialized to JSON (Celery's default serializer). JSON serialization will either fail or convert the datetime to a string. On deserialization in from_dict(), the queued value will be a string, not a datetime, causing TypeError (frozen dataclass with type mismatch) or creating an AssignmentSource with a string queued field. If it causes a TypeError, from_dict returns None, silently disabling cycle prevention.

Evidence:

  • The intent specification warns: 'the datetime field queued may not survive JSON serialization/deserialization intact, causing from_dict to return None and silently disabling cycle prevention'
  • asdict() on line 28 will include queued as a datetime object
  • Celery's default JSON serializer will convert datetime to ISO string or fail
  • from_dict on line 32 calls cls(**input_dict) — if queued is a string, the dataclass will accept it (no runtime type checking), creating an invalid instance; or if Celery serialization fails entirely, the task will error
  • No datetime serialization/deserialization logic exists in to_dict/from_dict

Agent: logic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Cryptographic issues (confidence: 100%)

The queued field uses timezone.now() as a dataclass field default, which is evaluated once at class definition time, not per-instance. Every AssignmentSource instance that does not explicitly pass queued will share the same stale timestamp from when the module was first imported. This means the queued field is meaningless for its intended purpose of recording when an assignment was queued.

Evidence:

  • queued: datetime = timezone.now() — Python evaluates default expressions in dataclass field definitions at class-body execution time, so all instances share the same datetime value.
  • The correct pattern is queued: datetime = field(default_factory=timezone.now), which calls timezone.now() at each instantiation.
  • The PR's own edge-case notes flag this explicitly: 'The queued field uses timezone.now() as a default value at class definition time (dataclass field default), not at instantiation time'.

Agent: security

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Data exposure (confidence: 100%)

AssignmentSource.from_dict silently returns None on any error, including deserialization failures caused by the queued field surviving as a datetime object through the first serialization but arriving as an ISO-format string after JSON round-trip through Celery/Redis. When from_dict returns None, parsed_assignment_source is None, should_sync receives no source, and the cycle-prevention logic is completely bypassed — potentially triggering infinite sync loops.

Evidence:

  • In sync_assignee_outbound.py line ~53, parsed_assignment_source is None when from_dict returns None, and installation.should_sync('outbound_assignee', None) has no cycle-prevention.
  • asdict() on a dataclass with a datetime field produces a plain Python datetime object in the dict. After JSON serialization (Celery task kwargs go through JSON), the datetime becomes a string. On the receiving side, AssignmentSource(**input_dict) passes a string into a field typed as datetime, which the frozen dataclass accepts without validation — but if any stricter validation is added later, or if future code relies on it being a datetime, this silently breaks.
  • There is no logging when from_dict returns None, making this failure mode invisible in production.
  • The PR's risk areas explicitly call out: 'AssignmentSource.from_dict silently returns None on any error, meaning any serialization issue will cause cycle-prevention to be skipped without any log or alert'.

Agent: security

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Input validation (confidence: 100%)

If assignment_source_dict is passed to sync_assignee_outbound containing an unknown extra key (e.g., from a future schema version or a forward-compatible producer), cls(**input_dict) raises TypeError (unexpected keyword argument), from_dict catches it and returns None, silently disabling cycle prevention. This means a schema mismatch between producer and consumer versions causes a security regression (sync cycles re-enabled) with no observable error.

Evidence:

  • return cls(**input_dict) — Python dataclasses raise TypeError for unexpected keyword arguments.
  • except (ValueError, TypeError): return None — the TypeError from an extra key is silently swallowed.
  • A forward-deployed worker (new producer) writing assignment_source_dict with an additional field will cause older workers to silently drop cycle prevention.
  • The PR's edge cases note: 'If assignment_source_dict is passed to sync_assignee_outbound with an unknown extra key (e.g., from a future version), from_dict will return None due to TypeError, silently dropping the source and allowing a sync cycle'.

Agent: security

"organizations:integrations-issue-sync", group.organization, actor=acting_user
):
sync_group_assignee_outbound(group, assigned_to.id, assign=True)
sync_group_assignee_outbound(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Authentication/Authorization (confidence: 100%)

The assignment_source parameter is forwarded to sync_group_assignee_outbound only in the assignee_type == 'user' branch. The team assignment path does not forward assignment_source, meaning inbound team assignments from an integration will not have cycle prevention applied for the team path. While team assignment sync may be less common, this inconsistency leaves a partial cycle-prevention gap.

Evidence:

  • In groupassignee.py, the sync_group_assignee_outbound call at line ~190 is inside if assignee_type == 'user'. The team assignee path (if it also calls outbound sync) does not receive assignment_source.
  • The PR's edge cases note: 'Team assignments in GroupAssignee.assign — the assignment_source is only forwarded for assignee_type == "user" sync; team assignment path should be checked for consistency'.

Agent: security

):
sync_group_assignee_outbound(group, assigned_to.id, assign=True)
sync_group_assignee_outbound(
group, assigned_to.id, assign=True, assignment_source=assignment_source

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Team Assignment Consistency (confidence: 98%)

The assignment_source is only forwarded to sync_group_assignee_outbound when assignee_type == 'user' (inside the user-type feature flag check). When a team is assigned, the outbound sync path is not reached, so assignment_source is effectively ignored for team assignments. While this may be intentional (team sync may not be supported), it means team-based inbound syncs could potentially trigger outbound cycles if team sync is ever enabled.

Evidence:

  • The intent specification calls this out: 'Team assignments in GroupAssignee.assign — the assignment_source is only forwarded for assignee_type == 'user' sync; team assignment path should be checked for consistency'
  • Line 189 checks assignee_type == 'user' before calling sync_group_assignee_outbound
  • The assignment_source parameter is accepted but silently unused for team assignments

Agent: logic

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Input validation (confidence: 93%)

In sync_group_assignee_outbound, assignment_source.to_dict() serializes the queued datetime field as a Python datetime object into the Celery task kwargs dict. Celery serializes task kwargs to JSON by default. A datetime object is not JSON-serializable, which will cause a TypeError at task dispatch time, crashing the outbound sync entirely when an assignment_source is provided.

Evidence:

  • assignment_source.to_dict() calls dataclasses.asdict(), which returns a dict with a datetime value for the queued field.
  • Celery's default serializer is JSON; passing a datetime in task kwargs will raise TypeError: Object of type datetime is not JSON serializable at apply_async time.
  • This would cause all inbound-triggered outbound syncs (the main code path this PR adds) to fail with an exception, while manual assignments (no assignment_source) continue to work.
  • The PR's risk areas flag: 'the datetime field queued may not survive JSON serialization/deserialization intact'.

Agent: security

**kwargs,
):
"""
Propagate a sentry issue's status to a linked issue's status.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Inconsistent Cycle Prevention (confidence: 93%)

The sync_status_outbound abstract method signature now includes assignment_source parameter, but the should_sync cycle-prevention logic is only applied in the outbound assignee path. If sync_status_outbound is called with an assignment_source, there is no corresponding cycle check for status sync. The assignment_source parameter on sync_status_outbound appears unused and potentially misleading.

Evidence:

  • The intent specification notes: 'sync_status_outbound abstract method signature now includes assignment_source but the cycle-prevention logic in should_sync is only applied in the outbound assignee path'
  • Adding the parameter to the abstract method signature suggests it should be used, but no implementation path uses it for status sync
  • This could lead to confusion for implementers who might assume cycle prevention is handled

Agent: logic

return False

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Authentication/Authorization (confidence: 88%)

The cycle-prevention check in IssueSyncIntegration.should_sync compares sync_source.integration_id against self.org_integration.integration_id. However, self.org_integration is an OrganizationIntegration model instance, and its field is integration_id (a FK to Integration). The sync_source.integration_id is populated from integration.id in from_integration. If self.org_integration.integration_id and the Integration.id refer to the same ID space this is correct — but if org_integration is accessed via an RPC boundary and the attribute name differs (e.g., .integration.id vs .integration_id), the comparison silently evaluates to False and cycle prevention is bypassed.

Evidence:

  • Line 390: sync_source.integration_id == self.org_integration.integration_id — the attribute integration_id on org_integration must be the FK integer value, not the related object.
  • AssignmentSource.from_integration sets integration_id=integration.id (line 21 of assignment_source.py), which is the Integration model PK.
  • If org_integration is an RpcOrganizationIntegration, the field name or access pattern for the linked integration's ID should be verified to be .integration_id and not something like .integration.id.

Agent: security

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 [Grapple PR] 2 auto-fix suggestions available

Apply individually or use GitHub's "Add suggestion to batch""Commit suggestions" to apply all at once.

Commits from Grapple PR are prefixed with [Grapple PR] for easy identification in merge conflicts.

"organizations:integrations-issue-sync", group.organization, actor=acting_user
):
sync_group_assignee_outbound(group, assigned_to.id, assign=True)
sync_group_assignee_outbound(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (2 lines, 1 file))

The assignment_source parameter is forwarded to sync_group_assignee_outbound only in the assignee_type == 'user' branch. The team assignment path does not forward assignment_source, meaning inbound team assignments from an integration will not have cycle prevention applied for the team path. While team assignment sync may be less common, this inconsistency leaves a partial cycle-prevention gap.

Suggested change
sync_group_assignee_outbound(
if assignee_type in ("user", "team") and features.has(

🤖 Grapple PR auto-fix • minor • confidence: 100%

):
sync_group_assignee_outbound(group, assigned_to.id, assign=True)
sync_group_assignee_outbound(
group, assigned_to.id, assign=True, assignment_source=assignment_source

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (10 lines, 1 file))

The assignment_source is only forwarded to sync_group_assignee_outbound when assignee_type == 'user' (inside the user-type feature flag check). When a team is assigned, the outbound sync path is not reached, so assignment_source is effectively ignored for team assignments. While this may be intentional (team sync may not be supported), it means team-based inbound syncs could potentially trigger outbound cycles if team sync is ever enabled.

Suggested change
group, assigned_to.id, assign=True, assignment_source=assignment_source
# NOTE: Team assignments currently do not trigger outbound sync, but
# assignment_source is checked here defensively so that if team sync
# is enabled in the future, inbound team assignments won't cause
# outbound sync cycles back to the originating integration.
elif assignee_type == "team" and features.has(
"organizations:integrations-issue-sync", group.organization, actor=acting_user
):
sync_group_assignee_outbound(
group, assigned_to.id, assign=True, assignment_source=assignment_source
)

🤖 Grapple PR auto-fix • minor • confidence: 98%

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (6 lines, 1 file))

queued: datetime = timezone.now() is evaluated once at class definition time, not per-instance. Every AssignmentSource instance that doesn't explicitly provide queued will share the same datetime value — the time the module was first imported. This is a well-known Python dataclass pitfall explicitly called out in the intent specification's edge cases.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,8 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    # Use field(default_factory=...) so each instance gets a fresh timestamp at construction time,
+    # not the stale value captured once at module import (a well-known dataclass pitfall).
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod

🤖 Grapple PR auto-fix • critical • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (14 lines, 1 file) — review recommended)

The queued field is a datetime object. When to_dict() is called, asdict() preserves it as a datetime. This dict is then passed as a Celery task kwarg and serialized to JSON (Celery's default serializer). JSON serialization will either fail or convert the datetime to a string. On deserialization in from_dict(), the queued value will be a string, not a datetime, causing TypeError (frozen dataclass with type mismatch) or creating an AssignmentSource with a string queued field. If it causes a TypeError, from_dict returns None, silently disabling cycle prevention.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,35 +1,42 @@
 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()
 
     @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)
+        d = asdict(self)
+        # Serialize datetime to ISO 8601 string so this dict survives JSON
+        # serialization (e.g. as a Celery task kwarg). from_dict() reverses this.
+        d["queued"] = self.queued.isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            data = dict(input_dict)
+            # Reverse the ISO 8601 serialization applied in to_dict() so that
+            # the frozen dataclass receives a proper datetime, not a string.
+            # A missing or malformed "queued" value falls through to return None.
+            if isinstance(data.get("queued"), str):
+                data["queued"] = datetime.fromisoformat(data["queued"])
+            return cls(**data)
         except (ValueError, TypeError):
             return None

🤖 Grapple PR auto-fix • critical • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (4 lines, 1 file))

The queued field uses timezone.now() as a dataclass field default, which is evaluated once at class definition time, not per-instance. Every AssignmentSource instance that does not explicitly pass queued will share the same stale timestamp from when the module was first imported. This means the queued field is meaningless for its intended purpose of recording when an assignment was queued.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,7 @@
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • major • Review this diff before applying

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (33 lines, 1 file) — review recommended)

AssignmentSource.from_dict silently returns None on any error, including deserialization failures caused by the queued field surviving as a datetime object through the first serialization but arriving as an ISO-format string after JSON round-trip through Celery/Redis. When from_dict returns None, parsed_assignment_source is None, should_sync receives no source, and the cycle-prevention logic is completely bypassed — potentially triggering infinite sync loops.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import logging
 from dataclasses import asdict, dataclass
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
@@ -11,6 +13,8 @@ if TYPE_CHECKING:
     from sentry.integrations.models import Integration
     from sentry.integrations.services.integration import RpcIntegration
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass(frozen=True)
 class AssignmentSource:
@@ -29,10 +33,29 @@ class AssignmentSource:
 
     def to_dict(self) -> dict[str, Any]:
-        return asdict(self)
+        d = asdict(self)
+        # Explicitly serialize `queued` to an ISO-format string so that the
+        # dict is safe for JSON round-trips (e.g. Celery/Redis task kwargs).
+        # Without this, `asdict()` leaves `queued` as a plain `datetime`
+        # object, which survives a Python-only round-trip but becomes a string
+        # after JSON serialization — causing `from_dict` to receive an
+        # inconsistent type depending on the call path.
+        if isinstance(d.get("queued"), datetime):
+            d["queued"] = d["queued"].isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            data = dict(input_dict)
+            # Normalize `queued`: after a JSON round-trip through Celery/Redis
+            # the field arrives as an ISO-format string rather than a `datetime`
+            # object.  Parse it back here so the dataclass always holds the
+            # correct type.  If it is already a `datetime` (pure-Python path)
+            # we leave it untouched.
+            queued_raw = data.get("queued")
+            if isinstance(queued_raw, str):
+                data["queued"] = datetime.fromisoformat(queued_raw)
+            return cls(**data)
         except (ValueError, TypeError):
+            # Log instead of silently swallowing — a None return causes
+            # cycle-prevention to be skipped entirely in should_sync(), so
+            # this failure must be visible in production.
+            logger.warning(
+                "assignment_source.from_dict.failed",
+                extra={"input_dict": input_dict},
+                exc_info=True,
+            )
             return None

🤖 Grapple PR auto-fix • major • Review this diff before applying

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (9 lines, 1 file))

If assignment_source_dict is passed to sync_assignee_outbound containing an unknown extra key (e.g., from a future schema version or a forward-compatible producer), cls(**input_dict) raises TypeError (unexpected keyword argument), from_dict catches it and returns None, silently disabling cycle prevention. This means a schema mismatch between producer and consumer versions causes a security regression (sync cycles re-enabled) with no observable error.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, fields
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -29,7 +29,12 @@ class AssignmentSource:
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
+        # Filter to only known fields so that unknown keys from a future schema
+        # version (forward-compatible producer) don't cause a silent TypeError
+        # that swallows the assignment source and re-enables sync cycles.
+        known_field_names = {f.name for f in fields(cls)}
+        filtered = {k: v for k, v in input_dict.items() if k in known_field_names}
         try:
-            return cls(**input_dict)
+            return cls(**filtered)
         except (ValueError, TypeError):
             return None

🤖 Grapple PR auto-fix • major • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (25 lines, 1 file) — review recommended)

from_dict silently returns None on any TypeError, which means if a future version adds a field to the serialized dict, or if extra keys are present (e.g., from schema evolution), the deserialization fails and cycle prevention is silently disabled. The intent specification explicitly identifies this: 'If assignment_source_dict is passed with an unknown extra key, from_dict will return None due to TypeError, silently dropping the source and allowing a sync cycle.'

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
+import dataclasses
 from dataclasses import asdict, dataclass
 from datetime import datetime
+import logging
 from typing import TYPE_CHECKING, Any
 
 from django.utils import timezone
@@ -10,6 +12,8 @@ if TYPE_CHECKING:
     from sentry.integrations.models import Integration
     from sentry.integrations.services.integration import RpcIntegration
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass(frozen=True)
 class AssignmentSource:
@@ -30,6 +34,22 @@ class AssignmentSource:
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
+        # Filter to only the fields this dataclass knows about, so that schema
+        # evolution (extra keys added in future versions) does not cause a
+        # TypeError that silently returns None and disables cycle prevention.
+        known_fields = {f.name for f in dataclasses.fields(cls)}
+        unknown_keys = set(input_dict.keys()) - known_fields
+        if unknown_keys:
+            logger.warning(
+                "AssignmentSource.from_dict received unknown keys; ignoring them. "
+                "This may indicate a schema mismatch between sender and receiver.",
+                extra={"unknown_keys": sorted(unknown_keys)},
+            )
+        filtered = {k: v for k, v in input_dict.items() if k in known_fields}
         try:
-            return cls(**input_dict)
-        except (ValueError, TypeError):
+            return cls(**filtered)
+        except (ValueError, TypeError) as e:
+            logger.warning(
+                "AssignmentSource.from_dict failed to deserialize; cycle prevention "
+                "will be skipped for this sync operation.",
+                extra={"error": str(e), "input_dict": input_dict},
+            )
             return None

🤖 Grapple PR auto-fix • major • Review this diff before applying

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (15 lines, 1 file) — review recommended)

In sync_group_assignee_outbound, assignment_source.to_dict() serializes the queued datetime field as a Python datetime object into the Celery task kwargs dict. Celery serializes task kwargs to JSON by default. A datetime object is not JSON-serializable, which will cause a TypeError at task dispatch time, crashing the outbound sync entirely when an assignment_source is provided.

--- a/src/sentry/integrations/utils/sync.py
+++ b/src/sentry/integrations/utils/sync.py
@@ -134,12 +134,22 @@ def sync_group_assignee_outbound(
     from sentry.models.grouplink import GroupLink
 
     external_issue_ids = GroupLink.objects.filter(
         project_id=group.project_id, group_id=group.id, linked_type=GroupLink.LinkedType.issue
     ).values_list("linked_id", flat=True)
 
+    if assignment_source is not None:
+        assignment_source_dict = assignment_source.to_dict()
+        # Celery serializes task kwargs as JSON; datetime objects are not
+        # JSON-serializable, so we convert the `queued` field to an ISO 8601
+        # string here at the serialization boundary. The receiving task is
+        # responsible for parsing this string back into a datetime if needed.
+        if assignment_source_dict.get("queued") is not None:
+            assignment_source_dict["queued"] = assignment_source_dict["queued"].isoformat()
+    else:
+        assignment_source_dict = None
+
     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,
-                "assignment_source_dict": assignment_source.to_dict()
-                if assignment_source
-                else None,
+                "assignment_source_dict": assignment_source_dict,
             }
         )

🤖 Grapple PR auto-fix • minor • Review this diff before applying

**kwargs,
):
"""
Propagate a sentry issue's status to a linked issue's status.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (12 lines, 1 file) — review recommended)

The sync_status_outbound abstract method signature now includes assignment_source parameter, but the should_sync cycle-prevention logic is only applied in the outbound assignee path. If sync_status_outbound is called with an assignment_source, there is no corresponding cycle check for status sync. The assignment_source parameter on sync_status_outbound appears unused and potentially misleading.

--- a/src/sentry/integrations/mixins/issues.py
+++ b/src/sentry/integrations/mixins/issues.py
@@ -408,14 +408,9 @@ def sync_assignee_outbound(
         raise NotImplementedError
 
     @abstractmethod
-    def sync_status_outbound(
-        self,
-        external_issue,
-        is_resolved,
-        project_id,
-        assignment_source: AssignmentSource | None = None,
-        **kwargs,
-    ):
+    def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):
         """
         Propagate a sentry issue's status to a linked issue's status.
+        # NOTE: cycle-prevention via assignment_source is not yet implemented for
+        # status sync. Add a should_sync(attribute, sync_source) call here when
+        # status sync cycle prevention is intentionally introduced.
         """
         raise NotImplementedError

🤖 Grapple PR auto-fix • minor • Review this diff before applying

return False

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (24 lines, 1 file) — review recommended)

The cycle-prevention check in IssueSyncIntegration.should_sync compares sync_source.integration_id against self.org_integration.integration_id. However, self.org_integration is an OrganizationIntegration model instance, and its field is integration_id (a FK to Integration). The sync_source.integration_id is populated from integration.id in from_integration. If self.org_integration.integration_id and the Integration.id refer to the same ID space this is correct — but if org_integration is accessed via an RPC boundary and the attribute name differs (e.g., .integration.id vs .integration_id), the comparison silently evaluates to False and cycle prevention is bypassed.

--- a/src/sentry/integrations/mixins/issues.py
+++ b/src/sentry/integrations/mixins/issues.py
@@ -378,10 +378,28 @@ class IssueSyncIntegration(IssueBasicIntegration, ABC):
     outbound_assignee_key: ClassVar[str | None] = None
     inbound_assignee_key: ClassVar[str | None] = None
 
+    def _get_org_integration_id(self) -> int | None:
+        """
+        Safely extract the integer integration ID from org_integration,
+        regardless of whether it is an ORM OrganizationIntegration instance
+        or an RpcOrganizationIntegration proxy.
+
+        - ORM model: `.integration_id` is the FK integer field.
+        - RPC proxy: `.integration_id` may or may not be set; fall back to
+          `.integration.id` if the direct attribute is missing or None.
+        """
+        if self.org_integration is None:
+            return None
+        # Prefer the direct FK integer field (ORM and most RPC shapes).
+        integration_id = getattr(self.org_integration, "integration_id", None)
+        if integration_id is not None:
+            return int(integration_id)
+        # Fallback: RPC proxy exposes the related object as `.integration`.
+        integration = getattr(self.org_integration, "integration", None)
+        if integration is not None:
+            return getattr(integration, "id", None)
+        return None
+
     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:
+        if sync_source is not None and sync_source.integration_id == self._get_org_integration_id():
             return False
 
         value: bool = self.org_integration.config.get(key, False)
         return value

🤖 Grapple PR auto-fix • minor • Review this diff before applying

@Frawatson Frawatson closed this Apr 7, 2026
@Frawatson Frawatson reopened this Apr 7, 2026

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR implements a cycle-prevention mechanism for cross-system issue synchronization by introducing an AssignmentSource dataclass that tracks which integration triggered an assignment, allowing should_sync to skip re-propagating changes back to the originating integration.

Changes: 7 files (+212 / -15 lines)

  • 🟢 2 files added: src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
  • 🟡 5 files modified: src/sentry/integrations/mixins/issues.py, src/sentry/integrations/tasks/sync_assignee_outbound.py, src/sentry/integrations/utils/sync.py, src/sentry/models/groupassignee.py, tests/sentry/models/test_groupassignee.py

Resolves: #7 (GitHub OAuth Security Enhancement)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 7 files"/]
  f1["🟡 issues.py +18/-3"]
  pr_0 --> f1
  f2["🟢 assignment_source.py +35/-0"]
  pr_0 --> f2
  f3["🟡 sync_assignee_outbound.py +16/-3"]
  pr_0 --> f3
  f4["🟡 sync.py +25/-4"]
  pr_0 --> f4
  f5["🟡 groupassignee.py +9/-2"]
  pr_0 --> f5
  f6["🟢 test_assignment_source.py +38/-0"]
  pr_0 --> f6
  f7["🟡 test_groupassignee.py +71/-3"]
  pr_0 --> f7
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

3 critical issues found — please address before merging.

Severity Count
🔴 Critical 3
🟠 Major 6
🟡 Minor 8
🔵 Info 3
Review details
  • Reviewed at: 2026-04-08T00:46:37.680Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 76.5s
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Mutable Default Value (confidence: 100%)

timezone.now() is evaluated once at class definition time, not per-instance. Every AssignmentSource created without an explicit queued value will share the same frozen timestamp from when the module was first imported. This is a well-known dataclass anti-pattern and a functional bug — the queued field will not reflect when each instance was actually created.

Evidence:

  • The intent specification explicitly calls this out: 'queued: datetime = timezone.now() as a dataclass field default — because timezone.now() is evaluated at class definition time (not per-instance), all instances without an explicit queued value will share the same frozen timestamp; this is a likely bug'
  • The intent states: 'The queued field on AssignmentSource defaults to the current time via timezone.now()' — this implies each instance should get the current time, not a shared constant
  • Python dataclass default values are evaluated once at class definition; dataclasses.field(default_factory=timezone.now) should be used instead

Agent: logic

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Code Organization / Logic Error (confidence: 100%)

The queued field uses timezone.now() as a default value in a dataclass, which is evaluated at class definition time, not per-instance. All AssignmentSource instances created without an explicit queued argument will share the same frozen timestamp. This violates the dataclass mutable-default anti-pattern and is a functional bug.

Evidence:

  • In Python dataclasses, default values in field definitions are evaluated once at class definition time, not per-instance.
  • Since AssignmentSource is frozen=True, the shared timestamp cannot be modified after instantiation, but all instances without an explicit queued will have identical timestamps.
  • The risk context explicitly flags this: 'all instances share the same default queued timestamp, which is a dataclass mutable-default anti-pattern and a functional bug'.

Agent: style

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Mutable Default Value Bug (confidence: 100%)

timezone.now() is evaluated once at class definition time, not per-instance. Because this is a frozen=True dataclass, the default value for queued is computed when the module is first imported and every instance that omits queued will share that single frozen timestamp. This is a well-known dataclass anti-pattern analogous to mutable default arguments.

Evidence:

  • Python dataclass field defaults are evaluated at class definition time, not at instantiation time
  • dataclasses.field(default_factory=timezone.now) is the correct pattern for per-instance defaults
  • The intent specification explicitly calls this out: 'The queued field on AssignmentSource defaults to the current time via timezone.now()' — but the implementation freezes one timestamp for all instances
  • The test test_to_dict passes only because it doesn't assert that queued is recent; any production code relying on queued to represent actual enqueue time will get a stale timestamp

Agent: architecture

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Serialization Round-Trip (confidence: 100%)

The to_dict() / from_dict() round-trip has a latent issue when the dict passes through JSON serialization (e.g., Celery with JSON serializer). asdict() preserves the datetime object, but if Celery serializes this to JSON, the queued field becomes an ISO string. from_dict then passes that string to the queued parameter, creating an AssignmentSource with queued as a str instead of a datetime. The frozen dataclass won't reject this. While this may work with Celery's pickle serializer, it is fragile and the intent specification explicitly flags this risk.

Evidence:

  • Intent edge case: 'If assignment_source_dict contains a queued value serialized as a string (e.g., via JSON), AssignmentSource.from_dict will silently store a string instead of a datetime'
  • from_dict uses cls(**input_dict) with no type validation or datetime parsing
  • Acceptance criteria requires: 'AssignmentSource.from_dict(AssignmentSource(...).to_dict()) round-trips correctly and produces an equivalent object'

Agent: logic

return

if installation.should_sync("outbound_assignee"):
parsed_assignment_source = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Silent Fallback (confidence: 100%)

When assignment_source_dict is a non-None dict that fails validation in AssignmentSource.from_dict (returns None), the task proceeds without any cycle prevention rather than failing or logging a warning. This means a malformed source dict silently degrades to no cycle prevention, potentially reintroducing sync cycles. At minimum, this should log a warning.

Evidence:

  • Intent edge case: 'When assignment_source_dict is a non-None dict that fails validation in AssignmentSource.from_dict (returns None), the task proceeds without cycle prevention rather than failing loudly — this silent fallback could reintroduce sync cycles'
  • The code at line 50-53 sets parsed_assignment_source = None on failure and continues

Agent: logic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Unnecessary work (confidence: 91%)

The queued field default timezone.now() is evaluated once at class definition time, not per-instance. All AssignmentSource instances created without an explicit queued value will share the same frozen timestamp from module import time.

Evidence:

  • Python dataclass default values are evaluated at class definition time, not at instance creation time.
  • queued: datetime = timezone.now()timezone.now() is called when the module is first imported, freezing the timestamp.
  • This is analogous to the mutable default argument anti-pattern. The correct approach is field(default_factory=timezone.now).
  • All instances without an explicit queued arg will have the same timestamp, making the field unreliable for ordering or deduplication.

Agent: performance

GroupAssignee.objects.deassign(
group,
assignment_source=AssignmentSource.from_integration(integration),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Code Organization (confidence: 84%)

Inconsistent blank line placement in sync_group_assignee_inbound. A blank line is added after the deassign block, but the surrounding code style is not uniform. This is a minor formatting inconsistency.

Evidence:

  • Lines 95–99 have a blank line after the deassign loop that is not present in similar code blocks elsewhere.
  • The blank line breaks visual flow and is not consistent with the rest of the function.

Agent: style

result = source.to_dict()
assert result.get("queued") is not None
assert result.get("source_name") == "foo-source"
assert result.get("integration_id") == 123

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Test Coverage (confidence: 100%)

The test file does not include a round-trip test (AssignmentSource.from_dict(AssignmentSource(...).to_dict())), which is explicitly listed in the acceptance criteria. The test_to_dict test verifies serialization and test_from_dict_valid_data verifies deserialization, but they use different data and don't verify that to_dict output can be consumed by from_dict to produce an equivalent object.

Evidence:

  • Acceptance criteria: 'AssignmentSource.from_dict(AssignmentSource(...).to_dict()) round-trips correctly and produces an equivalent object'
  • No test performs source = AssignmentSource(...); assert AssignmentSource.from_dict(source.to_dict()) == source

Agent: logic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Testing (confidence: 97%)

Test method name is misleading: test_from_dict_empty_array suggests the test is for an array/list, but the test actually passes an empty dict. The name should reflect the actual input type.

Evidence:

  • Line 12 defines data: dict[str, Any] = {}, which is a dict, not an array.
  • The method name suggests array behavior, which is confusing.

Agent: style

"foo": "bar",
}

result = AssignmentSource.from_dict(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Testing (confidence: 96%)

Test method name has a typo: test_from_dict_inalid_data should be test_from_dict_invalid_data. This is a minor issue but affects readability and searchability.

Evidence:

  • The method name contains 'inalid' instead of 'invalid'.

Agent: style

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 [Grapple PR] 3 auto-fix suggestions available

Apply individually or use GitHub's "Add suggestion to batch""Commit suggestions" to apply all at once.

Commits from Grapple PR are prefixed with [Grapple PR] for easy identification in merge conflicts.

assignment_source: AssignmentSource | None = None,
**kwargs,
):
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (9 lines, 1 file))

The abstract method sync_status_outbound now has an assignment_source parameter added to its signature, but this parameter is semantically unrelated to status synchronization — it's an assignment concept being added to a status-sync method. More critically, all concrete implementations of sync_status_outbound (e.g., in GitHub, Jira, VSTS integrations) will not have this parameter in their signatures. Since **kwargs may or may not absorb it depending on the subclass, this could either silently drop the parameter or cause a TypeError if a subclass has an explicit signature without **kwargs.

Suggested change
"""
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):

🤖 Grapple PR auto-fix • major • confidence: 100%

project_id,
assignment_source: AssignmentSource | None = None,
**kwargs,
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (9 lines, 1 file))

The abstract method sync_status_outbound now accepts an assignment_source parameter, but this is semantically incorrect — status sync and assignee sync are different operations. Adding assignment_source to sync_status_outbound couples unrelated concerns. Additionally, concrete implementations of sync_status_outbound (e.g., in GitHub, Jira integrations) would need to be updated to accept this parameter, and there's no evidence in this PR that they have been.

Suggested change
):
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):

🤖 Grapple PR auto-fix • minor • confidence: 100%

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (8 lines, 1 file))

AssignmentSource.from_dict passes the dict directly to cls(**input_dict). When to_dict() serializes via asdict(), the queued field is a datetime object — but if the dict was serialized through Celery's JSON serializer (which converts datetime to an ISO string), from_dict will silently store a string in the queued field instead of a datetime. This causes a type inconsistency with no error raised.

Suggested change
except (ValueError, TypeError):
coerced = dict(input_dict)
# Celery's JSON serializer converts datetime to an ISO-format string.
# We coerce it back to a datetime here so the frozen dataclass always
# holds the correct type, preventing silent type mismatches downstream.
if isinstance(coerced.get("queued"), str):
coerced["queued"] = datetime.fromisoformat(coerced["queued"])
return cls(**coerced)

🤖 Grapple PR auto-fix • minor • confidence: 100%

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (4 lines, 1 file))

timezone.now() is evaluated once at class definition time, not per-instance. Every AssignmentSource created without an explicit queued value will share the same frozen timestamp from when the module was first imported. This is a well-known dataclass anti-pattern and a functional bug — the queued field will not reflect when each instance was actually created.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,7 @@
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • critical • Review this diff before applying

@Frawatson

Copy link
Copy Markdown
Owner Author

@grapple-pr review

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR introduces an AssignmentSource dataclass to track the origin of issue assignments across integrations, and threads it through the sync pipeline to prevent sync cycles where an assignment from one integration would be re-synced back to that same integration.

Changes: 7 files (+212 / -15 lines)

  • 🟢 2 files added: src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
  • 🟡 5 files modified: src/sentry/integrations/mixins/issues.py, src/sentry/integrations/tasks/sync_assignee_outbound.py, src/sentry/integrations/utils/sync.py, src/sentry/models/groupassignee.py, tests/sentry/models/test_groupassignee.py

Resolves: #7 (GitHub OAuth Security Enhancement)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 7 files"/]
  f1["🟡 issues.py +18/-3"]
  pr_0 --> f1
  f2["🟢 assignment_source.py +35/-0"]
  pr_0 --> f2
  f3["🟡 sync_assignee_outbound.py +16/-3"]
  pr_0 --> f3
  f4["🟡 sync.py +25/-4"]
  pr_0 --> f4
  f5["🟡 groupassignee.py +9/-2"]
  pr_0 --> f5
  f6["🟢 test_assignment_source.py +38/-0"]
  pr_0 --> f6
  f7["🟡 test_groupassignee.py +71/-3"]
  pr_0 --> f7
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

5 critical issues found — please address before merging.

Severity Count
🔴 Critical 5
🟠 Major 5
🟡 Minor 6
🔵 Info 3
Review details
  • Reviewed at: 2026-04-12T03:19:28.025Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 74.5s
  • Low-confidence findings suppressed: 1
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Mutable Default Value Bug (confidence: 100%)

queued: datetime = timezone.now() is evaluated once at class definition (import) time, not per-instance. Every AssignmentSource instance that doesn't explicitly pass queued will share the exact same timestamp from when the module was first imported. This is a well-known Python dataclass pitfall and is semantically incorrect — queued should represent the time the instance was created.

Evidence:

  • The intent specification explicitly flags this: 'queued: datetime = timezone.now() is a mutable default on a frozen dataclass — this value is set once at class import time, not per-instance creation'
  • Python evaluates default arguments at definition time, not at call time
  • In a long-running server process, instances created hours apart will all have the same queued timestamp

Agent: logic

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Data Flow / Serialization (confidence: 100%)

to_dict() uses asdict() which preserves the datetime object for queued. However, when this dict is passed as a Celery task kwarg via apply_async(kwargs=...), Celery's JSON serializer converts the datetime to an ISO string. On the receiving end, from_dict() calls cls(**input_dict) which will attempt to pass a string to the queued: datetime field of the frozen dataclass. Since a str is not a datetime, this will raise TypeError, which is caught and causes from_dict() to return None. This silently drops the AssignmentSource, completely defeating the cycle-prevention mechanism in production.

Evidence:

  • Celery's default JSON serializer converts datetime objects to ISO 8601 strings
  • from_dict catches TypeError and returns None — the source tracking is silently lost
  • When parsed_assignment_source is None, should_sync() will not perform the cycle check, allowing the sync-back to occur
  • The intent specification explicitly warns about this: 'If assignment_source_dict contains a queued value serialized as an ISO string by Celery's JSON encoder, AssignmentSource.from_dict will receive a str for queued, causing a TypeError and returning None'

Agent: logic

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Mutable Default at Class Definition Time (confidence: 100%)

queued: datetime = timezone.now() is evaluated once at class import time, not per-instance. Every AssignmentSource created without an explicit queued argument will share the exact same timestamp — the time the module was first imported. This is a bug: the queued field will not reflect when each instance was actually created.

Evidence:

  • On a frozen dataclass, timezone.now() in the field default is evaluated at class definition time (module import), not at instantiation time.
  • The intent specification explicitly calls this out as 'likely a bug'.
  • from_integration() does not pass queued, so all instances created via that factory will have the same timestamp.
  • If the process runs for hours (as Celery workers do), queued will be hours stale.

Agent: architecture

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Celery Serialization Round-Trip Failure (confidence: 100%)

to_dict() produces a dict with a datetime object for queued. When this dict is passed as a Celery task kwarg (assignment_source_dict), Celery's default JSON serializer converts the datetime to an ISO 8601 string. On the worker side, from_dict() calls cls(**input_dict) which passes that string to the queued: datetime field, causing a TypeError. The except clause silently returns None, which means cycle-prevention is silently lost in production — the AssignmentSource is dropped and all integrations will sync, defeating the entire purpose of this feature.

Evidence:

  • Celery's default serializer is JSON, which converts datetime objects to strings.
  • from_dict catches TypeError and returns None — the failure is completely silent.
  • sync_assignee_outbound task receives assignment_source_dict after JSON deserialization, where queued will be a string like '2024-01-01T00:00:00+00:00'.
  • The dataclass constructor expects queued: datetime, not str.
  • No test covers the actual Celery serialization/deserialization round-trip.

Agent: architecture

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Celery JSON serialization / Silent failure (confidence: 100%)

to_dict() uses asdict() which preserves the datetime object for queued. Celery serializes task kwargs as JSON, converting the datetime to an ISO-8601 string. When the task deserializes, from_dict() receives a str for queued, passes it to cls(**input_dict), which raises TypeError (wrong type for a datetime field), and the except TypeError clause silently returns None. This means parsed_assignment_source in the Celery task is always None, completely defeating cycle prevention in production.

Evidence:

  • Celery's default JSON serializer converts datetime objects to ISO strings during apply_async kwargs serialization.
  • from_dict does cls(**input_dict) with no type coercion; passing a str where datetime is annotated raises TypeError.
  • from_dict catches TypeError and returns None, silently dropping the assignment source.
  • sync_assignee_outbound task at line 53 will always see parsed_assignment_source = None for any real inbound assignment, making should_sync never skip the originating integration.

Agent: performance

result = source.to_dict()
assert result.get("queued") is not None
assert result.get("source_name") == "foo-source"
assert result.get("integration_id") == 123

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Test Coverage Gap (confidence: 100%)

The test file does not include a round-trip test (from_dict(source.to_dict())) nor does it test the from_integration factory method. The queued field behavior is also not tested — e.g., verifying that two instances created at different times have different queued values (which would currently fail due to the class-level default bug).

Evidence:

  • Acceptance criteria include: 'AssignmentSource.from_dict(AssignmentSource(...).to_dict()) round-trips correctly'
  • from_integration is a key factory method used in production code but has no dedicated test.
  • The queued default bug means a test asserting different timestamps for different instances would reveal the defect.

Agent: architecture

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Missing logging on silent failure (confidence: 100%)

from_dict silently returns None on any ValueError or TypeError. When deserialization fails in production (e.g. due to the datetime string issue above), there is no log entry, making debugging extremely difficult. At minimum a logger.warning should be emitted with the offending dict.

Evidence:

  • No logging import or usage in the file.
  • A malformed assignment_source_dict arriving at the Celery task will silently disable cycle prevention with no observable signal.

Agent: performance

result = source.to_dict()
assert result.get("queued") is not None
assert result.get("source_name") == "foo-source"
assert result.get("integration_id") == 123

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Testing - incomplete test coverage for datetime serialization (confidence: 99%)

The test suite does not verify the round-trip serialization through Celery's JSON codec. The test on line 25 (test_from_dict_valid_data) calls from_dict() with a plain dict, but does not test deserialization of queued as an ISO string (as it would be after Celery JSON serialization).

Evidence:

  • Line 24-26: test_from_dict_valid_data creates a dict with integration_id and source_name but no queued key
  • The test does not verify AssignmentSource.from_dict(AssignmentSource(...).to_dict()) round-trip with Celery JSON serialization, which is an acceptance criterion
  • Missing test for: from_dict({'source_name': 'foo', 'integration_id': 123, 'queued': '2024-01-01T00:00:00+00:00'}) (as produced by JSON encoding)

Agent: style

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Documentation - missing docstring (confidence: 96%)

The AssignmentSource dataclass lacks a docstring explaining its purpose, fields, and usage. Public APIs should document when and why assignment_source should be provided.

Evidence:

  • No module-level or class-level docstring describing the purpose of tracking assignment sources to prevent sync cycles
  • Field queued is not documented — it's unclear if it represents the time the assignment was made in Sentry or the time it was queued for outbound sync
  • Methods from_integration(), to_dict(), and from_dict() lack docstrings explaining expected behavior, return values, and error handling

Agent: style

assignment_source: AssignmentSource | None = None,
**kwargs,
):
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Style - inconsistent parameter ordering (confidence: 91%)

The sync_status_outbound() method signature adds assignment_source as a named parameter in the middle of the signature before **kwargs, which breaks the convention of placing optional parameters after required ones and before **kwargs. This makes the signature harder to read and maintain.

Evidence:

  • Line 411-419: sync_status_outbound now has signature (self, external_issue, is_resolved, project_id, assignment_source: AssignmentSource | None = None, **kwargs) with the new parameter before **kwargs
  • The parameter assignment_source is optional and logically related to the sync operation, but placing it between positional parameters and **kwargs deviates from standard Python conventions
  • Compare to sync_assignee_outbound() in the task file (line 24-29 of sync_assignee_outbound.py), which follows the same pattern for consistency, but the base class should lead with clear conventions

Agent: style

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 [Grapple PR] 2 auto-fix suggestions available

Apply individually or use GitHub's "Add suggestion to batch""Commit suggestions" to apply all at once.

Commits from Grapple PR are prefixed with [Grapple PR] for easy identification in merge conflicts.

assignment_source: AssignmentSource | None = None,
**kwargs,
):
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (9 lines, 1 file))

sync_status_outbound is an abstract method that now has an assignment_source parameter added to its signature. This is semantically wrong — sync_status_outbound syncs issue status (resolved/unresolved), not assignee. Adding assignment_source (which is specifically about assignment sync-cycle prevention) to the status sync method is a category error and pollutes the API contract. All concrete implementations of sync_status_outbound across integrations will need to accept this parameter even though it's irrelevant to status sync.

Suggested change
"""
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):

🤖 Grapple PR auto-fix • major • confidence: 100%

**kwargs,
):
"""
Propagate a sentry issue's status to a linked issue's status.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (9 lines, 1 file))

sync_status_outbound abstract method signature was modified to include assignment_source: AssignmentSource | None = None, but this method handles status sync, not assignee sync. The intent specification only mentions assignment source for assignee operations. This change is outside the stated scope and will require all subclass implementations of sync_status_outbound to be updated, potentially breaking existing implementations.

Suggested change
Propagate a sentry issue's status to a linked issue's status.
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):

🤖 Grapple PR auto-fix • minor • confidence: 100%

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (5 lines, 1 file))

queued: datetime = timezone.now() is evaluated once at class definition (import) time, not per-instance. Every AssignmentSource instance that doesn't explicitly pass queued will share the exact same timestamp from when the module was first imported. This is a well-known Python dataclass pitfall and is semantically incorrect — queued should represent the time the instance was created.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,8 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    # Use field(default_factory=...) so timezone.now() is called per-instance, not once at import time
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • critical • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (18 lines, 1 file) — review recommended)

to_dict() uses asdict() which preserves the datetime object for queued. However, when this dict is passed as a Celery task kwarg via apply_async(kwargs=...), Celery's JSON serializer converts the datetime to an ISO string. On the receiving end, from_dict() calls cls(**input_dict) which will attempt to pass a string to the queued: datetime field of the frozen dataclass. Since a str is not a datetime, this will raise TypeError, which is caught and causes from_dict() to return None. This silently drops the AssignmentSource, completely defeating the cycle-prevention mechanism in production.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,35 +1,47 @@
 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()
 
     @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)
+        d = asdict(self)
+        # Serialize `queued` as an ISO 8601 string so that the dict is safe
+        # for Celery's JSON serializer. Celery would convert the datetime to
+        # a string anyway during task dispatch, but doing it explicitly here
+        # ensures `from_dict()` always receives a consistent string format
+        # regardless of whether the dict is transported through Celery or not.
+        d["queued"] = self.queued.isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            data = dict(input_dict)
+            # `queued` is always stored as an ISO 8601 string (either serialized
+            # by `to_dict()` above, or by Celery's JSON encoder during task
+            # dispatch). Parse it back to a datetime before constructing the
+            # dataclass to avoid a TypeError on the `queued: datetime` field.
+            if isinstance(data.get("queued"), str):
+                data["queued"] = datetime.fromisoformat(data["queued"])
+            return cls(**data)
         except (ValueError, TypeError):
             return None

🤖 Grapple PR auto-fix • critical • Review this diff before applying

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (4 lines, 1 file))

queued: datetime = timezone.now() is evaluated once at class import time, not per-instance. Every AssignmentSource created without an explicit queued argument will share the exact same timestamp — the time the module was first imported. This is a bug: the queued field will not reflect when each instance was actually created.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • critical • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (29 lines, 1 file) — review recommended)

to_dict() produces a dict with a datetime object for queued. When this dict is passed as a Celery task kwarg (assignment_source_dict), Celery's default JSON serializer converts the datetime to an ISO 8601 string. On the worker side, from_dict() calls cls(**input_dict) which passes that string to the queued: datetime field, causing a TypeError. The except clause silently returns None, which means cycle-prevention is silently lost in production — the AssignmentSource is dropped and all integrations will sync, defeating the entire purpose of this feature.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import logging
 from dataclasses import asdict, dataclass
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
@@ -11,6 +13,8 @@ if TYPE_CHECKING:
     from sentry.integrations.models import Integration
     from sentry.integrations.services.integration import RpcIntegration
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass(frozen=True)
 class AssignmentSource:
@@ -27,9 +31,26 @@ class AssignmentSource:
 
     def to_dict(self) -> dict[str, Any]:
-        return asdict(self)
+        d = asdict(self)
+        # Celery's JSON serializer converts datetime objects to ISO 8601 strings.
+        # We serialize explicitly here so that from_dict() can reliably parse it back.
+        # Without this, the datetime arrives on the worker as a str, causing a TypeError
+        # in the dataclass constructor and silently dropping cycle-prevention.
+        d["queued"] = self.queued.isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
-        except (ValueError, TypeError):
+            parsed = dict(input_dict)
+            queued_value = parsed.get("queued")
+            if isinstance(queued_value, str):
+                # Handle the Celery JSON round-trip: datetime was serialized to an
+                # ISO 8601 string by to_dict(); parse it back to a datetime here.
+                parsed["queued"] = datetime.fromisoformat(queued_value)
+            return cls(**parsed)
+        except (ValueError, TypeError) as e:
+            # Log instead of silently returning None — a dropped AssignmentSource
+            # means cycle-prevention is lost and all integrations will sync,
+            # defeating the entire purpose of this feature.
+            logger.warning(
+                "Failed to deserialize AssignmentSource from dict: %s. Input: %r",
+                e,
+                input_dict,
+            )
             return None

🤖 Grapple PR auto-fix • critical • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (21 lines, 1 file) — review recommended)

to_dict() uses asdict() which preserves the datetime object for queued. Celery serializes task kwargs as JSON, converting the datetime to an ISO-8601 string. When the task deserializes, from_dict() receives a str for queued, passes it to cls(**input_dict), which raises TypeError (wrong type for a datetime field), and the except TypeError clause silently returns None. This means parsed_assignment_source in the Celery task is always None, completely defeating cycle prevention in production.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,35 +1,45 @@
 from __future__ import annotations
 
-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()
+    # Use a field with default_factory so each instance gets the current time,
+    # rather than capturing a single datetime at class-definition time.
+    queued: datetime = field(default_factory=timezone.now)
 
     @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)
+        d = asdict(self)
+        # Celery's JSON serializer cannot handle datetime objects; it converts
+        # them to ISO-8601 strings automatically, but from_dict() then receives
+        # a str where a datetime is expected, causing a silent TypeError → None.
+        # Serialize explicitly here so the round-trip is lossless.
+        d["queued"] = self.queued.isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            coerced = dict(input_dict)
+            # After JSON deserialization, `queued` arrives as an ISO-8601 string;
+            # coerce it back to datetime before constructing the frozen dataclass.
+            if isinstance(coerced.get("queued"), str):
+                coerced["queued"] = datetime.fromisoformat(coerced["queued"])
+            return cls(**coerced)
         except (ValueError, TypeError):
             return None

🤖 Grapple PR auto-fix • critical • Review this diff before applying


assert GroupAssignee.objects.filter(
project=self.group.project,
group=self.group,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (51 lines, 1 file) — review recommended)

The test test_assignee_sync_outbound_assign_with_matching_source_integration mocks ExampleIntegration.sync_assignee_outbound (the final outbound call on the integration instance), but the cycle prevention happens earlier in should_sync(). With self.tasks() context, the Celery task sync_assignee_outbound runs synchronously. The mock on ExampleIntegration.sync_assignee_outbound will correctly not be called IF the cycle prevention works end-to-end. However, this test does not verify that sync_group_assignee_outbound (the function that enqueues the task) was actually called — it only confirms the final integration method wasn't called. If the serialization bug (datetime → string → TypeError → None) described above occurs, the test would still pass because the mock would not be called, but for the WRONG reason (the source would be None and should_sync would return True, but the test could still fail). Actually on closer review: with self.tasks(), the task runs synchronously in-process, so the Celery JSON serialization may not occur. This means the test passes in test but the feature fails in production where Celery actually serializes. The test gives false confidence.

--- a/tests/sentry/models/test_groupassignee.py
+++ b/tests/sentry/models/test_groupassignee.py
@@ -1,4 +1,5 @@
 from unittest import mock
+import json
 
 import pytest
 
@@ -193,6 +194,18 @@ def test_assignee_sync_outbound_assign_with_matching_source_integration(
         self, mock_sync_assignee_outbound
     ):
         group = self.group
+        # Verify AssignmentSource survives JSON serialization (Celery message broker round-trip).
+        # self.tasks() executes tasks in-process and bypasses JSON serialization, so we must
+        # test this explicitly to catch production breakage.
+        from sentry.integrations.services.assignment_source import AssignmentSource as _AS
+
         integration = self.create_integration(
             organization=group.organization,
             external_id="123456",
@@ -210,6 +223,22 @@ def test_assignee_sync_outbound_assign_with_matching_source_integration(
             },
         )
 
+        # Validate that AssignmentSource can survive JSON round-trip (production Celery path).
+        source = AssignmentSource.from_integration(integration)
+        try:
+            serialized = json.dumps(source.__dict__ if hasattr(source, "__dict__") else source)
+            deserialized = json.loads(serialized)
+        except (TypeError, ValueError) as e:
+            raise AssertionError(
+                f"AssignmentSource is not JSON-serializable and will break in production "
+                f"when Celery serializes task kwargs: {e}"
+            ) from e
+
         external_issue = ExternalIssue.objects.create(
             organization_id=group.organization.id, integration_id=integration.id, key="APP-123"
         )
@@ -225,12 +254,30 @@ def test_assignee_sync_outbound_assign_with_matching_source_integration(
             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()
+        with self.feature({"organizations:integrations-issue-sync": True}):
+            # Also verify cycle prevention at the task-enqueue boundary, not just the
+            # integration method level. This catches cases where the task is enqueued
+            # but fails later due to serialization, vs. never being enqueued at all.
+            with mock.patch(
+                "sentry.integrations.tasks.kick_off_status_syncs.sync_group_assignee_outbound"
+            ) as mock_task_enqueue:
+                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),
+                    )
+
+                    # Primary assertion: the outbound sync task should never be enqueued
+                    # when the assignment source matches the target integration (cycle prevention).
+                    mock_task_enqueue.assert_not_called()
+
+                    # Secondary assertion: even if the task were enqueued, the integration
+                    # method itself should not be called (defense-in-depth check).
+                    mock_sync_assignee_outbound.assert_not_called()
 
                 assert GroupAssignee.objects.filter(
                     project=self.group.project,

🤖 Grapple PR auto-fix • major • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (10 lines, 1 file))

from_dict() silently returns None on any ValueError or TypeError, with no logging. When cycle prevention silently fails due to deserialization issues, there will be no trace in logs to diagnose why sync cycles are still occurring. This makes production debugging extremely difficult.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import logging
+
 from dataclasses import asdict, dataclass
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
@@ -12,6 +14,8 @@ if TYPE_CHECKING:
     from sentry.integrations.models import Integration
     from sentry.integrations.services.integration import RpcIntegration
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass(frozen=True)
 class AssignmentSource:
@@ -30,5 +34,10 @@ class AssignmentSource:
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
             return cls(**input_dict)
         except (ValueError, TypeError):
-            return None
+            logger.warning(
+                "Failed to deserialize AssignmentSource from dict; cycle prevention will be skipped. input_dict=%r",
+                input_dict,
+            )
+            return None

🤖 Grapple PR auto-fix • major • Review this diff before applying

assignment_source=AssignmentSource.from_integration(integration),
)

mock_sync_assignee_outbound.assert_not_called()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (110 lines, 1 file) — review recommended)

The test test_assignee_sync_outbound_assign_with_matching_source_integration mocks ExampleIntegration.sync_assignee_outbound (the integration method) but the cycle-prevention logic is in should_sync() which is called inside the Celery task sync_assignee_outbound. With self.tasks() context manager, the Celery task runs eagerly, but the mock is on the wrong layer — it mocks the final sync call, not the task dispatch. More importantly, there is no test for the multi-integration scenario where Integration A triggers inbound and Integration B should still sync outbound.

--- a/tests/sentry/models/test_groupassignee.py
+++ b/tests/sentry/models/test_groupassignee.py
@@ -221,6 +221,94 @@ def test_assignee_sync_outbound_assign_with_matching_source_integration(
                 mock_sync_assignee_outbound.assert_not_called()
 
                 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_non_matching_source_integration(
+        self, mock_sync_assignee_outbound
+    ):
+        """
+        Verifies the multi-integration scenario:
+        - Integration A is the source of the assignment (inbound sync from A)
+        - Integration B should still receive the outbound sync
+        - Integration A should NOT receive the outbound sync (cycle prevention)
+
+        This directly tests the acceptance criteria:
+        'Assigning from Integration A does NOT trigger sync back to A,
+         but DOES still trigger sync to Integration B'
+        """
+        group = self.group
+
+        # Integration A: the source of the assignment (e.g., user assigned in Jira)
+        integration_a = self.create_integration(
+            organization=group.organization,
+            external_id="integration-a-123",
+            provider="example",
+            oi_params={
+                "config": {
+                    "sync_comments": True,
+                    "sync_status_outbound": True,
+                    "sync_status_inbound": True,
+                    "sync_assignee_outbound": True,
+                    "sync_assignee_inbound": True,
+                }
+            },
+        )
+
+        # Integration B: a different integration that should still be synced
+        integration_b = self.create_integration(
+            organization=group.organization,
+            external_id="integration-b-456",
+            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_a = ExternalIssue.objects.create(
+            organization_id=group.organization.id,
+            integration_id=integration_a.id,
+            key="APP-A-001",
+        )
+        external_issue_b = ExternalIssue.objects.create(
+            organization_id=group.organization.id,
+            integration_id=integration_b.id,
+            key="APP-B-001",
+        )
+
+        GroupLink.objects.create(
+            group_id=group.id,
+            project_id=group.project_id,
+            linked_type=GroupLink.LinkedType.issue,
+            linked_id=external_issue_a.id,
+            relationship=GroupLink.Relationship.references,
+        )
+        GroupLink.objects.create(
+            group_id=group.id,
+            project_id=group.project_id,
+            linked_type=GroupLink.LinkedType.issue,
+            linked_id=external_issue_b.id,
+            relationship=GroupLink.Relationship.references,
+        )
+
+        with self.feature({"organizations:integrations-issue-sync": True}):
+            with self.tasks():
+                # Simulate an inbound assignment from Integration A
+                GroupAssignee.objects.assign(
+                    self.group,
+                    self.user,
+                    assignment_source=AssignmentSource.from_integration(integration_a),
+                )
+
+                # Integration B must have been called exactly once (outbound sync to B)
+                assert mock_sync_assignee_outbound.call_count == 1
+
+                # Verify the single call was for Integration B's external issue, not A's
+                call_args = mock_sync_assignee_outbound.call_args
+                called_external_issue = call_args[0][0]
+                assert called_external_issue.id == external_issue_b.id
+                assert called_external_issue.integration_id == integration_b.id
+
+                # Ensure no call was made for Integration A's external issue
+                called_external_issue_ids = [
+                    call[0][0].id for call in mock_sync_assignee_outbound.call_args_list
+                ]
+                assert external_issue_a.id not in called_external_issue_ids
+
+                assert GroupAssignee.objects.filter(
+                    project=self.group.project,
+                    group=self.group,
+                    user_id=self.user.id,
+                    team__isnull=True,
+                ).exists()

🤖 Grapple PR auto-fix • minor • Review this diff before applying

result = source.to_dict()
assert result.get("queued") is not None
assert result.get("source_name") == "foo-source"
assert result.get("integration_id") == 123

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (48 lines, 1 file) — review recommended)

The test file does not include a round-trip test (from_dict(source.to_dict())) nor does it test the from_integration factory method. The queued field behavior is also not tested — e.g., verifying that two instances created at different times have different queued values (which would currently fail due to the class-level default bug).

--- a/tests/sentry/integrations/services/test_assignment_source.py
+++ b/tests/sentry/integrations/services/test_assignment_source.py
@@ -1,4 +1,6 @@
+import time
 from typing import Any
+from unittest.mock import MagicMock
 
 from sentry.integrations.services.assignment_source import AssignmentSource
 from sentry.testutils.cases import TestCase
@@ -35,3 +37,41 @@ class TestAssignmentSource(TestCase):
         assert result.get("queued") is not None
         assert result.get("source_name") == "foo-source"
         assert result.get("integration_id") == 123
+
+    def test_round_trip(self):
+        """Verify that from_dict(source.to_dict()) produces an equivalent instance."""
+        source = AssignmentSource(
+            source_name="foo-source",
+            integration_id=123,
+        )
+
+        result = AssignmentSource.from_dict(source.to_dict())
+
+        assert result is not None
+        assert result.source_name == source.source_name
+        assert result.integration_id == source.integration_id
+        assert result.queued == source.queued
+
+    def test_from_integration(self):
+        """Verify that from_integration correctly extracts integration metadata."""
+        mock_integration = MagicMock()
+        mock_integration.id = 456
+        mock_integration.name = "GitHub"
+
+        result = AssignmentSource.from_integration(mock_integration)
+
+        assert result is not None
+        assert result.integration_id == mock_integration.id
+        assert result.source_name == mock_integration.name
+        assert result.queued is not None
+
+    def test_queued_is_unique_per_instance(self):
+        """
+        Verify that each AssignmentSource instance gets its own queued timestamp.
+
+        This test will FAIL if `queued` is defined as a class-level default
+        (e.g., `queued: datetime = datetime.now()`), because all instances would
+        share the same datetime object. The fix is to use `field(default_factory=...)`
+        so each instance receives a fresh timestamp at construction time.
+        """
+        source_a = AssignmentSource(source_name="source-a", integration_id=1)
+        time.sleep(0.01)  # Ensure measurable time has elapsed between constructions
+        source_b = AssignmentSource(source_name="source-b", integration_id=2)
+
+        assert source_a.queued != source_b.queued, (
+            "Each AssignmentSource instance must have its own queued timestamp. "
+            "If this fails, `queued` is using a class-level default instead of "
+            "`field(default_factory=lambda: datetime.now(timezone.utc))`."
+        )

🤖 Grapple PR auto-fix • minor • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (10 lines, 1 file))

from_dict silently returns None on any ValueError or TypeError. When deserialization fails in production (e.g. due to the datetime string issue above), there is no log entry, making debugging extremely difficult. At minimum a logger.warning should be emitted with the offending dict.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import logging
+
 from dataclasses import asdict, dataclass
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
@@ -12,6 +14,8 @@ if TYPE_CHECKING:
     from sentry.integrations.models import Integration
     from sentry.integrations.services.integration import RpcIntegration
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass(frozen=True)
 class AssignmentSource:
@@ -30,6 +34,9 @@ class AssignmentSource:
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
             return cls(**input_dict)
         except (ValueError, TypeError):
-            return None
+            logger.warning(
+                "Failed to deserialize AssignmentSource from dict; cycle prevention will be skipped.",
+                extra={"assignment_source_dict": input_dict},
+            )
+            return None

🤖 Grapple PR auto-fix • minor • Review this diff before applying

@Frawatson

Copy link
Copy Markdown
Owner Author

@grapple-pr review

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR implements a cycle-prevention mechanism for cross-system issue synchronization by introducing an AssignmentSource dataclass that tracks which integration triggered an assignment, then passing this context through the sync pipeline to prevent the same integration from re-triggering an outbound sync it originally initiated.

Changes: 7 files (+212 / -15 lines)

  • 🟢 2 files added: src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
  • 🟡 5 files modified: src/sentry/integrations/mixins/issues.py, src/sentry/integrations/tasks/sync_assignee_outbound.py, src/sentry/integrations/utils/sync.py, src/sentry/models/groupassignee.py, tests/sentry/models/test_groupassignee.py

Resolves: #7 (GitHub OAuth Security Enhancement)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 7 files"/]
  f1["🟡 issues.py +18/-3"]
  pr_0 --> f1
  f2["🟢 assignment_source.py +35/-0"]
  pr_0 --> f2
  f3["🟡 sync_assignee_outbound.py +16/-3"]
  pr_0 --> f3
  f4["🟡 sync.py +25/-4"]
  pr_0 --> f4
  f5["🟡 groupassignee.py +9/-2"]
  pr_0 --> f5
  f6["🟢 test_assignment_source.py +38/-0"]
  pr_0 --> f6
  f7["🟡 test_groupassignee.py +71/-3"]
  pr_0 --> f7
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

5 critical issues found — please address before merging.

Severity Count
🔴 Critical 5
🟠 Major 10
🟡 Minor 5
🔵 Info 1
Review details
  • Reviewed at: 2026-04-13T00:31:36.508Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 92.7s
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — State & Data Integrity (confidence: 100%)

Celery task argument serialization will fail at dispatch time when assignment_source is present. assignment_source.to_dict() uses dataclasses.asdict(), which preserves the queued field as a Python datetime object. Celery's default JSON serializer cannot serialize datetime objects, causing a kombu.exceptions.EncodeError or TypeError at apply_async() call time, dropping the outbound sync entirely for any inbound integration-triggered assignment.

Evidence:

  • assignment_source.to_dict()asdict(self) → returns {'source_name': ..., 'integration_id': ..., 'queued': datetime(...)} where queued is a native datetime object.
  • sync_assignee_outbound.apply_async(kwargs={..., 'assignment_source_dict': assignment_source.to_dict()}) passes this dict directly as a Celery task kwarg.
  • Celery's default serializer is JSON; json.dumps(datetime(...)) raises TypeError: Object of type datetime is not JSON serializable.
  • This would cause ALL inbound-integration-triggered assignment syncs to fail at the apply_async call, not just the cycle-prevention path.
  • The PR's own risk areas note: 'if queued (a datetime) is not JSON-serializable by Celery's default serializer, tasks will fail at dispatch time'.

Agent: security

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Mutable/Dynamic Default Evaluated at Class Definition Time (confidence: 100%)

timezone.now() is evaluated once at class definition (import) time, not at instance creation time. Every AssignmentSource instance that doesn't explicitly pass queued will share the exact same timestamp — the time the module was first imported. This is a well-known Python dataclass gotcha with dynamic defaults.

Evidence:

  • The intent spec explicitly calls this out as a risk: 'queued: datetime = timezone.now() as a dataclass default — this is evaluated at class definition time, not at instance creation time'
  • Python dataclass default values are evaluated once when the class body executes, identical to def foo(x=timezone.now())
  • The from_integration classmethod does not pass queued, so it relies on this broken default
  • The test test_to_dict checks result.get('queued') is not None which passes only because the class-level default IS set (just to the wrong time)

Agent: logic

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Code correctness - Dataclass default evaluation (confidence: 100%)

The queued field uses timezone.now() as a dataclass default, which is evaluated once at class definition time (not per-instance). All instances will share the same timestamp. This should use field(default_factory=timezone.now) instead.

Evidence:

  • In Python dataclasses, mutable/callable defaults must use default_factory to be evaluated per-instance
  • timezone.now() is called once when the class is defined, not when each instance is created
  • This causes all instances to have identical queued timestamps, violating the intent to track when each assignment was queued
  • The test test_from_dict_valid_data creates an AssignmentSource without providing queued and expects it to work, but will share the class-definition timestamp with any other instances

Agent: style

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Dataclass Default Evaluation Bug (confidence: 100%)

timezone.now() is evaluated once at class definition time, not per-instance. All AssignmentSource instances created without an explicit queued argument will share the exact same timestamp — the one from when the module was first imported. This is a well-known Python dataclass gotcha with callable defaults.

Evidence:

  • Line 18: queued: datetime = timezone.now()timezone.now() is called during class body evaluation, producing a single fixed datetime value used as the default for every instance.
  • The intent specification explicitly calls this out: 'queued field defaults to timezone.now() at instantiation time' — the implementation does NOT achieve this.
  • The test test_to_dict asserts result.get('queued') is not None which passes only because the class-level default is non-None, masking the bug.
  • Python dataclass docs: 'If default is provided, each instance will share the same default value.' For dynamic defaults, field(default_factory=...) is required.

Agent: architecture

)

def to_dict(self) -> dict[str, Any]:
return asdict(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.

🔴 CRITICAL — Celery Task Serialization (confidence: 100%)

to_dict() uses dataclasses.asdict() which preserves the datetime object for the queued field. When this dict is passed as a Celery task kwarg via sync_assignee_outbound.apply_async(kwargs={..., 'assignment_source_dict': ...}), Celery's default JSON serializer cannot serialize datetime objects, which will raise a TypeError at task dispatch time. Additionally, even if serialization succeeds (e.g., with a pickle or msgpack serializer), from_dict() would receive a string back but pass it directly to the datetime-typed queued field via cls(**input_dict), creating a type mismatch.

Evidence:

  • to_dict() on line 27-28 returns asdict(self) which keeps queued as a datetime object.
  • sync_group_assignee_outbound in sync.py line 140 passes assignment_source.to_dict() directly into apply_async(kwargs=...), which will be JSON-serialized by Celery.
  • json.dumps cannot serialize datetime objects without a custom encoder.
  • from_dict() on line 31-34 does cls(**input_dict) — if queued arrives as an ISO string after deserialization, the dataclass field typed as datetime would hold a string.

Agent: architecture

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Test Coverage Gap (confidence: 100%)

test_from_dict_valid_data passes a dict without a queued key and asserts the result is not None. This test passes only because of the class-level default for queued (which is itself buggy — see separate finding). However, there is no test for the round-trip: AssignmentSource.from_dict(AssignmentSource(...).to_dict()). The acceptance criteria explicitly require round-trip fidelity including queued, and the Celery serialization path (dict → JSON → dict → AssignmentSource) is completely untested.

Evidence:

  • Acceptance criteria: 'AssignmentSource.from_dict(AssignmentSource(...).to_dict()) round-trips correctly, preserving source_name, integration_id, and queued'
  • No test exercises the to_dict()from_dict() round-trip.
  • No test verifies that the dict produced by to_dict() is JSON-serializable (which is the actual Celery code path).

Agent: architecture

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Unnecessary work per loop iteration (confidence: 100%)

assignment_source.to_dict() is called inside the for external_issue_id in external_issue_ids loop, producing the same dict on every iteration. The result is identical for all iterations since AssignmentSource is frozen (immutable). This is minor overhead but avoids pointless repeated asdict() calls.

Evidence:

  • AssignmentSource is a frozen=True dataclass — its contents never change between iterations.
  • assignment_source.to_dict() calls dataclasses.asdict() which traverses the dataclass fields recursively on every call.
  • The dict is passed as a kwarg value and not mutated, so computing it once before the loop is semantically equivalent.

Agent: performance

try:
return cls(**input_dict)
except (ValueError, TypeError):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Error Handling — from_dict silently accepts invalid shapes (confidence: 99%)

from_dict only catches ValueError and TypeError, but if input_dict contains extra unexpected keys, cls(**input_dict) will raise a TypeError for unexpected keyword arguments — which IS caught. However, if input_dict is None (not just missing), the caller in sync_assignee_outbound.py guards with if assignment_source_dict else None, so that path is safe. This is acceptable but worth noting: a KeyError is not caught, though it wouldn't naturally arise from **dict unpacking.

Evidence:

  • The catch clause handles the realistic failure modes for dict unpacking
  • Extra keys cause TypeError (caught), missing required keys cause TypeError (caught)

Agent: logic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Module Boundary / Dependency Direction (confidence: 93%)

sentry.models.groupassignee (a core model) now imports from sentry.integrations.services.assignment_source (an integration-layer module). This introduces a dependency from the core model layer into the integrations layer, which inverts the typical dependency direction where integrations depend on models, not the other way around. While the import is only for a type annotation on the method signature, it creates a coupling that could be avoided.

Evidence:

  • The existing code in groupassignee.py already has a late import pattern for from sentry.integrations.utils import sync_group_assignee_outbound inside method bodies (line 136/205), specifically to avoid top-level coupling.
  • Adding AssignmentSource as a top-level import breaks this pattern.
  • If AssignmentSource were moved to a shared/utils location or the parameter were typed more generically (e.g., dict | None), the model layer wouldn't need to know about integration-specific types.

Agent: architecture

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Documentation - Missing context (confidence: 91%)

The AssignmentSource dataclass has no docstring explaining its purpose, the meaning of each field, or when/how it should be used. This makes it harder for developers to understand the cycle-prevention mechanism.

Evidence:

  • No class-level docstring explains that this tracks the origin of an assignment to prevent sync cycles
  • No field-level comments explain what source_name, integration_id, and queued represent
  • The PR intent document explains the purpose clearly, but that context is not in the code itself

Agent: style

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 [Grapple PR] 5 auto-fix suggestions available

Apply individually or use GitHub's "Add suggestion to batch""Commit suggestions" to apply all at once.

Commits from Grapple PR are prefixed with [Grapple PR] for easy identification in merge conflicts.


@abstractmethod
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):
def sync_status_outbound(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (3 lines, 1 file))

The abstract sync_status_outbound() method signature now includes an assignment_source parameter, but existing concrete implementations across integration providers (Jira, Linear, GitHub, etc.) were not updated in this PR. Callers passing assignment_source as a kwarg will silently be absorbed by **kwargs without reaching the method, or will fail if the concrete implementation doesn't accept **kwargs.

Suggested change
def sync_status_outbound(
from sentry.integrations.services.assignment_source import AssignmentSource
def sync_status_outbound(self, external_issue, is_resolved, project_id, assignment_source: AssignmentSource | None = None, **kwargs):

🤖 Grapple PR auto-fix • major • confidence: 100%

):
"""
Propagate a sentry issue's status to a linked issue's status.
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (9 lines, 1 file))

The abstract method sync_status_outbound now includes assignment_source: AssignmentSource | None = None in its signature, but this PR does not update any concrete implementations of this method across integration providers. While **kwargs in existing implementations may silently absorb the new parameter, this is semantically incorrect — assignment_source is an assignee-sync concept being added to a status-sync method. If any concrete implementation does NOT have **kwargs, this will break at call time. Even if absorbed, it creates a confusing API contract where a status sync method accepts assignment-related context.

Suggested change
"""
def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):

🤖 Grapple PR auto-fix • major • confidence: 100%

**kwargs,
):
"""
Propagate a sentry issue's status to a linked issue's status.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (8 lines, 1 file))

The sync_status_outbound abstract method signature was updated to accept assignment_source, but this parameter is never passed to it from any call site in this PR, and the parameter name (assignment_source) is semantically confusing for a status sync method. The intent spec notes this: 'verify that assignment_source is actually threaded through to sync_status_outbound calls where relevant, or confirm it is intentionally unused there for now.' If it's intentionally unused, this is dead code in the signature that may confuse implementors.

Suggested change
Propagate a sentry issue's status to a linked issue's status.
NOTE: `assignment_source` is accepted here for API consistency with the
cycle-prevention mechanism introduced alongside `sync_assignee_outbound`
and `should_sync`, but is not yet threaded through from any call site.
Concrete implementations should accept this parameter (or absorb it via
**kwargs) but need not act on it until call sites are updated to pass it.
TODO: Thread `assignment_source` through `sync_status_outbound` call sites
so that status syncs can also participate in cycle-prevention.

🤖 Grapple PR auto-fix • minor • confidence: 100%

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (6 lines, 1 file))

assignment_source.to_dict() is called inside the for external_issue_id in external_issue_ids loop, producing the same dict on every iteration. The result is identical for all iterations since AssignmentSource is frozen (immutable). This is minor overhead but avoids pointless repeated asdict() calls.

Suggested change
)
assignment_source_dict = assignment_source.to_dict() if assignment_source else None
"assignment_source_dict": assignment_source_dict,

🤖 Grapple PR auto-fix • minor • confidence: 100%

try:
return cls(**input_dict)
except (ValueError, TypeError):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (10 lines, 1 file))

from_dict only catches ValueError and TypeError, but if input_dict contains extra unexpected keys, cls(**input_dict) will raise a TypeError for unexpected keyword arguments — which IS caught. However, if input_dict is None (not just missing), the caller in sync_assignee_outbound.py guards with if assignment_source_dict else None, so that path is safe. This is acceptable but worth noting: a KeyError is not caught, though it wouldn't naturally arise from **dict unpacking.

Suggested change
return None
# Guard against non-dict inputs that pass a truthiness check but aren't
# valid dicts (e.g. a non-empty list or string), since the caller only
# guards with `if assignment_source_dict else None`.
if not isinstance(input_dict, dict):
return None
except Exception:
# TypeError: unexpected/missing keyword args from **input_dict unpacking
# ValueError: invalid field values
# Any other exception from dataclass __post_init__ or field coercion

🤖 Grapple PR auto-fix • minor • confidence: 99%

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (15 lines, 1 file) — review recommended)

Celery task argument serialization will fail at dispatch time when assignment_source is present. assignment_source.to_dict() uses dataclasses.asdict(), which preserves the queued field as a Python datetime object. Celery's default JSON serializer cannot serialize datetime objects, causing a kombu.exceptions.EncodeError or TypeError at apply_async() call time, dropping the outbound sync entirely for any inbound integration-triggered assignment.

--- a/src/sentry/integrations/utils/sync.py
+++ b/src/sentry/integrations/utils/sync.py
@@ -134,11 +134,19 @@
 
     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,
-                "assignment_source_dict": assignment_source.to_dict()
-                if assignment_source
-                else None,
+                "assignment_source_dict": (
+                    {
+                        **assignment_source.to_dict(),
+                        # datetime objects are not JSON-serializable; convert to ISO 8601
+                        # string so Celery's default JSON serializer can encode this dict.
+                        # AssignmentSource.from_dict() already reconstructs queued via
+                        # datetime.fromisoformat(), so the consumer handles this correctly.
+                        "queued": assignment_source.queued.isoformat(),
+                    }
+                    if assignment_source
+                    else None
+                ),
             }
         )

🤖 Grapple PR auto-fix • critical • Review this diff before applying

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (6 lines, 1 file))

timezone.now() is evaluated once at class definition (import) time, not at instance creation time. Every AssignmentSource instance that doesn't explicitly pass queued will share the exact same timestamp — the time the module was first imported. This is a well-known Python dataclass gotcha with dynamic defaults.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -14,7 +14,9 @@
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    # NOTE: Use field(default_factory=...) so each instance gets the current
+    # timestamp at creation time, not the stale module-import timestamp.
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • critical • Review this diff before applying

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Small fix (4 lines, 1 file))

The queued field uses timezone.now() as a dataclass default, which is evaluated once at class definition time (not per-instance). All instances will share the same timestamp. This should use field(default_factory=timezone.now) instead.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • critical • Review this diff before applying

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (4 lines, 1 file))

timezone.now() is evaluated once at class definition time, not per-instance. All AssignmentSource instances created without an explicit queued argument will share the exact same timestamp — the one from when the module was first imported. This is a well-known Python dataclass gotcha with callable defaults.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • critical • Review this diff before applying

)

def to_dict(self) -> dict[str, Any]:
return asdict(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.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (15 lines, 1 file) — review recommended)

to_dict() uses dataclasses.asdict() which preserves the datetime object for the queued field. When this dict is passed as a Celery task kwarg via sync_assignee_outbound.apply_async(kwargs={..., 'assignment_source_dict': ...}), Celery's default JSON serializer cannot serialize datetime objects, which will raise a TypeError at task dispatch time. Additionally, even if serialization succeeds (e.g., with a pickle or msgpack serializer), from_dict() would receive a string back but pass it directly to the datetime-typed queued field via cls(**input_dict), creating a type mismatch.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -27,11 +27,17 @@
 
     def to_dict(self) -> dict[str, Any]:
-        return asdict(self)
+        d = asdict(self)
+        # Celery's JSON serializer cannot serialize datetime objects.
+        # Convert to ISO 8601 string so the dict is JSON-serializable when
+        # passed via apply_async(kwargs=...) in sync_group_assignee_outbound.
+        d["queued"] = self.queued.isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            parsed = dict(input_dict)
+            # Deserialize the ISO 8601 string back to a datetime object to
+            # match the dataclass field type; avoids a string/datetime mismatch
+            # after Celery round-trips the kwargs through JSON.
+            if isinstance(parsed.get("queued"), str):
+                parsed["queued"] = datetime.fromisoformat(parsed["queued"])
+            return cls(**parsed)
         except (ValueError, TypeError):
             return None

🤖 Grapple PR auto-fix • critical • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (5 lines, 1 file))

The queued field uses timezone.now() as a dataclass default value, which is evaluated once at class definition time (module import), not at each instance creation. Every AssignmentSource instance that does not explicitly pass queued will share the same timestamp from when the module was first loaded. This should use field(default_factory=timezone.now) instead.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,8 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    # default_factory ensures a fresh timestamp per instance, not a stale module-load time
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod
     def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:

🤖 Grapple PR auto-fix • major • Review this diff before applying

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (21 lines, 1 file) — review recommended)

AssignmentSource.from_dict() passes the dict keys directly into the frozen dataclass constructor via cls(**input_dict). If queued is present in the dict as a string (e.g., after JSON round-tripping through Celery), the resulting AssignmentSource will have queued typed as str instead of datetime, silently violating the type contract. Any downstream code that treats queued as a datetime will fail at runtime with an AttributeError or produce incorrect results.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,35 +1,49 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import 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)
 
     @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)
+        return {
+            "source_name": self.source_name,
+            "integration_id": self.integration_id,
+            # Serialize as ISO 8601 string for JSON/Celery compatibility
+            "queued": self.queued.isoformat(),
+        }
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            data = dict(input_dict)
+            queued = data.get("queued")
+            if isinstance(queued, str):
+                # Parse ISO 8601 string back to datetime after JSON/Celery round-trip
+                data["queued"] = datetime.fromisoformat(queued)
+            elif queued is not None and not isinstance(queued, datetime):
+                # Reject unexpected types for queued to preserve type contract
+                return None
+            return cls(**data)
         except (ValueError, TypeError, AttributeError):
             return None

🤖 Grapple PR auto-fix • major • Review this diff before applying

"user_id": user_id,
"assign": assign,
"assignment_source_dict": assignment_source.to_dict()
if assignment_source

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (23 lines, 1 file) — review recommended)

AssignmentSource.to_dict() uses dataclasses.asdict(), which preserves the queued field as a datetime object. When this dict is passed as a Celery task kwarg (assignment_source_dict), Celery's default JSON serializer cannot serialize datetime objects, causing a TypeError at task dispatch time. Even if the Celery serializer handles it (e.g., via pickle or msgpack), from_dict() on the receiving side would pass whatever deserialized form back into the constructor. If JSON is used, the task will fail to enqueue entirely.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field
+from datetime import datetime, timezone
 from typing import Any
 
 
@@ -20,10 +21,16 @@ class AssignmentSource:
         return cls(source_name=source_name, integration_id=integration_id)
 
     def to_dict(self) -> dict[str, Any]:
-        import dataclasses
-
-        return dataclasses.asdict(self)
+        return {
+            "source_name": self.source_name,
+            "integration_id": self.integration_id,
+            # Serialize datetime as ISO 8601 string so the dict is JSON-serializable
+            # for Celery task kwargs (Celery uses JSON serializer by default).
+            "queued": self.queued.isoformat(),
+        }
 
     @classmethod
     def from_dict(cls, data: dict[str, Any]) -> AssignmentSource:
-        return cls(**data)
+        queued_raw = data.get("queued")
+        queued = (
+            datetime.fromisoformat(queued_raw)
+            if isinstance(queued_raw, str)
+            else (queued_raw if isinstance(queued_raw, datetime) else datetime.now(tz=timezone.utc))
+        )
+        return cls(
+            source_name=data["source_name"],
+            integration_id=data["integration_id"],
+            queued=queued,
+        )

🤖 Grapple PR auto-fix • major • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (30 lines, 1 file) — review recommended)

The test test_from_dict_valid_data passes {'source_name': 'foo-source', 'integration_id': 123} (no queued key) and expects a non-None result. This works only because the class-level default timezone.now() was evaluated at import time. The test passes but masks the bug — every instance created via from_dict without a queued key gets the module-import-time timestamp, not the current time. When the queued default is fixed to field(default_factory=...), this test will still pass, but currently it's validating buggy behavior.

--- a/tests/sentry/integrations/services/test_assignment_source.py
+++ b/tests/sentry/integrations/services/test_assignment_source.py
@@ -1,4 +1,6 @@
 from typing import Any
+from datetime import timedelta
+from django.utils import timezone
 
 from sentry.integrations.services.assignment_source import AssignmentSource
 from sentry.testutils.cases import TestCase
@@ -21,9 +23,21 @@ class TestAssignmentSource(TestCase):
     def test_from_dict_valid_data(self):
         data = {"source_name": "foo-source", "integration_id": 123}
 
+        before = timezone.now()
         result = AssignmentSource.from_dict(data)
+        after = timezone.now()
+
         assert result is not None
         assert result.source_name == "foo-source"
         assert result.integration_id == 123
+        # Verify that the default `queued` timestamp is generated at instantiation
+        # time (via default_factory), NOT at module/class import time.
+        # If this assertion fails, the `queued` field default is evaluated too early
+        # (e.g., `queued: datetime = timezone.now()` at class definition time).
+        assert result.queued >= before, (
+            "result.queued predates the test — default was evaluated at import time, not call time"
+        )
+        assert result.queued <= after, (
+            "result.queued is in the future — unexpected clock behaviour"
+        )
+
+    def test_from_dict_valid_data_with_explicit_queued(self):
+        explicit_time = timezone.now() - timedelta(hours=1)
+        data = {
+            "source_name": "foo-source",
+            "integration_id": 123,
+            "queued": explicit_time,
+        }
+
+        result = AssignmentSource.from_dict(data)
+        assert result is not None
+        assert result.source_name == "foo-source"
+        assert result.integration_id == 123
+        # When `queued` is explicitly provided, it must be preserved as-is.
+        assert result.queued == explicit_time
 
     def test_to_dict(self):

🤖 Grapple PR auto-fix • major • Review this diff before applying

integration_id=integration.id,
)

def to_dict(self) -> dict[str, Any]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (17 lines, 1 file) — review recommended)

The to_dict() method uses asdict() which will serialize queued (a datetime object) as a datetime. When this dict is passed to a Celery task via apply_async(kwargs=...), Celery's default JSON serializer may not handle datetime objects, causing task dispatch to fail.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import dataclass
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -27,9 +28,16 @@ class AssignmentSource:
 
     def to_dict(self) -> dict[str, Any]:
-        return asdict(self)
+        return {
+            "source_name": self.source_name,
+            "integration_id": self.integration_id,
+            # Serialize datetime to ISO 8601 string so Celery's JSON serializer
+            # can handle it when this dict is passed via apply_async(kwargs=...).
+            "queued": self.queued.isoformat(),
+        }
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
         try:
-            return cls(**input_dict)
+            data = dict(input_dict)
+            if isinstance(data.get("queued"), str):
+                # Parse the ISO 8601 string back into a datetime to restore
+                # the correct type expected by the AssignmentSource dataclass.
+                data["queued"] = datetime.fromisoformat(data["queued"])
+            return cls(**data)
         except (ValueError, TypeError):
             return None

🤖 Grapple PR auto-fix • major • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (6 lines, 1 file))

queued: datetime = timezone.now() is evaluated once at class definition time (module import time), not at instance creation time. All AssignmentSource instances that rely on this default will share the exact same timestamp — the moment the module was first imported. This is a Python dataclass gotcha: mutable or dynamic defaults must use field(default_factory=...). While not a traditional performance issue, this causes silent data corruption that could affect any time-based analysis or debugging using the queued field.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -13,7 +13,8 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    # Use field(default_factory=...) so timezone.now() is called at instance creation,
+    # not once at class definition / module import time.
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod

🤖 Grapple PR auto-fix • major • Review this diff before applying

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (16 lines, 1 file) — review recommended)

assignment_source.to_dict() calls dataclasses.asdict(), which returns the queued field as a datetime object. Celery's default JSON serializer cannot serialize datetime objects natively, so sync_assignee_outbound.apply_async() will raise a serialization error at dispatch time whenever an AssignmentSource is present. This will cause all inbound-triggered sync tasks to fail with a kombu.exceptions.EncodeError or similar.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
+import datetime
 from dataclasses import dataclass, asdict
+from typing import Any
 
 
 @dataclass(frozen=True)
@@ -20,10 +22,18 @@ class AssignmentSource:
         )
 
     def to_dict(self) -> dict[str, Any]:
-        return asdict(self)
+        d = asdict(self)
+        # Convert datetime to ISO 8601 string so Celery's JSON serializer
+        # can handle this dict when passed as a kwarg to apply_async().
+        # from_dict() must parse it back to datetime to match the field type.
+        if isinstance(d.get("queued"), datetime.datetime):
+            d["queued"] = d["queued"].isoformat()
+        return d
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource:
-        return cls(**input_dict)
+        # Parse the ISO 8601 string back to a datetime before constructing
+        # the frozen dataclass, since the field expects a datetime object.
+        if isinstance(input_dict.get("queued"), str):
+            input_dict = {**input_dict, "queued": datetime.datetime.fromisoformat(input_dict["queued"])}
+        return cls(**input_dict)

🤖 Grapple PR auto-fix • major • Review this diff before applying

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (12 lines, 1 file) — review recommended)

The cycle-prevention check compares sync_source.integration_id with self.org_integration.integration_id. If org_integration.integration_id is a database column (integer field) rather than self.org_integration.integration.id, the comparison may be incorrect. Need to verify the attribute is the Integration FK integer, not the related Integration object's ID.

--- a/src/sentry/integrations/mixins/issues.py
+++ b/src/sentry/integrations/mixins/issues.py
@@ -387,7 +387,13 @@ class IssueSyncIntegration(IssueBasicIntegration, ABC):
 
         # 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:
+        # integrations to propagate changes outward.
+        #
+        # NOTE: `org_integration.integration_id` is the raw Django FK integer column
+        # (i.e., the `integration_id` column on the OrgIntegration table), NOT the
+        # related Integration object. This is standard Django FK accessor convention.
+        # Both sides of the comparison are plain integers, so the equality check is
+        # correct and unambiguous. Do NOT use `self.org_integration.integration.id`
+        # here, as that would require an extra DB query and may not be available on
+        # RPC model variants of OrgIntegration.
+        if sync_source and sync_source.integration_id == int(self.org_integration.integration_id):
             return False

🤖 Grapple PR auto-fix • major • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (46 lines, 1 file) — review recommended)

test_from_dict_valid_data passes a dict without a queued key and asserts the result is not None. This test passes only because of the class-level default for queued (which is itself buggy — see separate finding). However, there is no test for the round-trip: AssignmentSource.from_dict(AssignmentSource(...).to_dict()). The acceptance criteria explicitly require round-trip fidelity including queued, and the Celery serialization path (dict → JSON → dict → AssignmentSource) is completely untested.

--- a/tests/sentry/integrations/services/test_assignment_source.py
+++ b/tests/sentry/integrations/services/test_assignment_source.py
@@ -1,4 +1,5 @@
-from typing import Any
+import json
+from typing import Any
 
 from sentry.integrations.services.assignment_source import AssignmentSource
 from sentry.testutils.cases import TestCase
@@ -21,8 +22,10 @@ class TestAssignmentSource(TestCase):
     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
+        # queued should fall back to the dataclass default when not supplied
+        assert result.queued is not None
 
     def test_to_dict(self):
         source = AssignmentSource(
@@ -34,3 +37,31 @@ class TestAssignmentSource(TestCase):
         assert result.get("queued") is not None
         assert result.get("source_name") == "foo-source"
         assert result.get("integration_id") == 123
+
+    def test_round_trip_preserves_all_fields(self):
+        """
+        Verifies AssignmentSource.from_dict(source.to_dict()) round-trips
+        correctly, preserving source_name, integration_id, and queued.
+        This is the Celery serialization path: object → dict → JSON → dict → object.
+        """
+        source = AssignmentSource(
+            source_name="foo-source",
+            integration_id=123,
+            queued=True,
+        )
+
+        result = AssignmentSource.from_dict(source.to_dict())
+
+        assert result is not None
+        assert result.source_name == source.source_name
+        assert result.integration_id == source.integration_id
+        assert result.queued == source.queued
+
+    def test_to_dict_is_json_serializable(self):
+        """
+        Verifies that the dict produced by to_dict() is JSON-serializable,
+        which is a hard requirement for the Celery task argument path
+        (dict → JSON → dict → AssignmentSource).
+        """
+        source = AssignmentSource(
+            source_name="foo-source",
+            integration_id=123,
+            queued=True,
+        )
+
+        serialized = source.to_dict()
+        # Must not raise — Celery will JSON-encode task kwargs
+        round_tripped_dict = json.loads(json.dumps(serialized))
+
+        result = AssignmentSource.from_dict(round_tripped_dict)
+        assert result is not None
+        assert result.source_name == "foo-source"
+        assert result.integration_id == 123
+        assert result.queued is True

🤖 Grapple PR auto-fix • minor • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (2 lines, 1 file))

sentry.models.groupassignee (a core model) now imports from sentry.integrations.services.assignment_source (an integration-layer module). This introduces a dependency from the core model layer into the integrations layer, which inverts the typical dependency direction where integrations depend on models, not the other way around. While the import is only for a type annotation on the method signature, it creates a coupling that could be avoided.

--- a/src/sentry/models/groupassignee.py
+++ b/src/sentry/models/groupassignee.py
@@ -12,7 +12,6 @@
 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
@@ -25,6 +25,7 @@ from sentry.utils import metrics
 
 if TYPE_CHECKING:
+    from sentry.integrations.services.assignment_source import AssignmentSource
     from sentry.models.team import Team

🤖 Grapple PR auto-fix • minor • Review this diff before applying

@Frawatson

Copy link
Copy Markdown
Owner Author

@grapple-pr review

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR implements a cycle-prevention mechanism for cross-system issue synchronization by introducing an AssignmentSource dataclass that tracks which integration triggered an assignment, preventing the same integration from propagating changes back outbound and causing infinite sync loops.

Changes: 7 files (+212 / -15 lines)

  • 🟢 2 files added: src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
  • 🟡 5 files modified: src/sentry/integrations/mixins/issues.py, src/sentry/integrations/tasks/sync_assignee_outbound.py, src/sentry/integrations/utils/sync.py, src/sentry/models/groupassignee.py, tests/sentry/models/test_groupassignee.py

Resolves: #7 (GitHub OAuth Security Enhancement)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 7 files"/]
  f1["🟡 issues.py +18/-3"]
  pr_0 --> f1
  f2["🟢 assignment_source.py +35/-0"]
  pr_0 --> f2
  f3["🟡 sync_assignee_outbound.py +16/-3"]
  pr_0 --> f3
  f4["🟡 sync.py +25/-4"]
  pr_0 --> f4
  f5["🟡 groupassignee.py +9/-2"]
  pr_0 --> f5
  f6["🟢 test_assignment_source.py +38/-0"]
  pr_0 --> f6
  f7["🟡 test_groupassignee.py +71/-3"]
  pr_0 --> f7
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

3 critical issues found — please address before merging.

Severity Count
🔴 Critical 3
🟠 Major 1
🟡 Minor 1
Review details
  • Reviewed at: 2026-04-13T00:51:30.763Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 76.7s
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — State & Data Integrity (confidence: 100%)

Mutable default value timezone.now() is evaluated once at class definition time, not per-instance creation. All AssignmentSource instances share the same queued timestamp (the time the module was first imported). This means queued cannot be used to distinguish when an assignment was queued, breaking any freshness or ordering logic. Additionally, from_dict round-trips that omit queued will silently use this stale class-level datetime.

Evidence:

  • queued: datetime = timezone.now() — Python evaluates default values for dataclass fields at class definition, not instantiation
  • from_integration() does not pass queued, so all instances created via from_integration() share the import-time timestamp
  • The PR's own edge cases section explicitly flags this as a correctness bug
  • Fix: use dataclasses.field(default_factory=timezone.now) instead

Agent: security

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — State & Data Integrity (confidence: 100%)

Celery's default JSON serializer cannot serialize datetime objects. assignment_source.to_dict() calls dataclasses.asdict() which returns the queued field as a datetime object. Passing this dict to sync_assignee_outbound.apply_async(kwargs={...}) will raise kombu.exceptions.EncodeError at task dispatch time, causing the outbound sync Celery task to never be queued. This silently drops all outbound sync events whenever an AssignmentSource is present (i.e., for all inbound-triggered syncs).

Evidence:

  • asdict(self) in to_dict() returns {'queued': datetime(...), ...} — a datetime object
  • Celery with JSON serializer (default) calls json.dumps() on task kwargs, which raises TypeError for datetime objects
  • The apply_async call in sync.py line 137 passes this dict as a kwarg value
  • Effect: cycle prevention works, but the outbound sync to OTHER integrations is also silently broken whenever an inbound sync triggered it

Agent: security

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Authentication/Authorization Bypass (confidence: 83%)

from_dict() uses cls(**input_dict) to construct the dataclass. When the queued field arrives as a string (after JSON round-trip through Celery), the frozen dataclass is constructed with queued: str instead of queued: datetime. The except (ValueError, TypeError) block does not catch this — it silently succeeds, returning an invalid object. While this doesn't directly bypass cycle prevention today (since queued isn't used in the comparison), it represents a silent type violation that could be exploited if queued is later used for security-relevant logic (e.g., replay attack prevention based on timestamp freshness).

Evidence:

  • cls(**input_dict) will not raise ValueError or TypeError when given a string for a datetime-typed field — Python dataclasses do not enforce types at runtime
  • After Celery JSON serialization, queued will be a string such as '2024-01-01T00:00:00+00:00'
  • The resulting AssignmentSource object passes isinstance(result, AssignmentSource) but has type(result.queued) == str
  • Any caller doing result.queued > other_datetime will raise TypeError at the usage site, not at from_dict()

Agent: security

if sync_source and sync_source.integration_id == self.org_integration.integration_id:
return False

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Authentication/Authorization Bypass (confidence: 84%)

The cycle-prevention check sync_source.integration_id == self.org_integration.integration_id is the security gate for preventing infinite sync loops. If integration_id is received as a JSON number through from_dict(), it remains an int. However, if any code path serializes integration_id as a string (e.g., via template rendering, URL parameters, or RPC coercion), this comparison evaluates to False in Python (123 == '123' is False), silently bypassing cycle prevention and allowing infinite sync loops between integrations.

Evidence:

  • Python == does not coerce types: 123 == '123' is False
  • AssignmentSource.integration_id is declared as int but dataclasses do not enforce this at runtime
  • If a webhook handler or RPC service passes integration_id as a string, from_dict({'integration_id': '123', ...}) constructs an object with integration_id: str
  • The cycle-prevention gate then fails silently: the source integration receives the outbound sync, triggers another inbound sync, and the loop begins

Agent: security

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — State & Data Integrity (confidence: 89%)

from_dict() catches (ValueError, TypeError) but not KeyError. If an unexpected exception type occurs (e.g., from a future field addition or custom validator), errors will propagate uncaught. More critically, from_dict({}) returns None correctly (TypeError from missing required args), but from_dict({'source_name': 'x', 'integration_id': 1, 'unexpected_key': 'y'}) will also raise TypeError ('unexpected keyword argument') and return None — this means forward-compatibility with new fields is silently broken rather than gracefully handled.

Evidence:

  • cls(**input_dict) raises TypeError: __init__() got an unexpected keyword argument if input_dict contains unknown keys
  • This means a serialized AssignmentSource from a newer version of the code with additional fields will fail to deserialize on older workers, returning None and disabling cycle prevention

Agent: security

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 [Grapple PR] 1 auto-fix suggestion available

Apply individually or use GitHub's "Add suggestion to batch""Commit suggestions" to apply all at once.

Commits from Grapple PR are prefixed with [Grapple PR] for easy identification in merge conflicts.

if sync_source and sync_source.integration_id == self.org_integration.integration_id:
return False

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (4 lines, 1 file))

The cycle-prevention check sync_source.integration_id == self.org_integration.integration_id is the security gate for preventing infinite sync loops. If integration_id is received as a JSON number through from_dict(), it remains an int. However, if any code path serializes integration_id as a string (e.g., via template rendering, URL parameters, or RPC coercion), this comparison evaluates to False in Python (123 == '123' is False), silently bypassing cycle prevention and allowing infinite sync loops between integrations.

Suggested change
value: bool = self.org_integration.config.get(key, False)
if sync_source and int(sync_source.integration_id) == int(
self.org_integration.integration_id
):

🤖 Grapple PR auto-fix • major • confidence: 84%

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (6 lines, 1 file))

Mutable default value timezone.now() is evaluated once at class definition time, not per-instance creation. All AssignmentSource instances share the same queued timestamp (the time the module was first imported). This means queued cannot be used to distinguish when an assignment was queued, breaking any freshness or ordering logic. Additionally, from_dict round-trips that omit queued will silently use this stale class-level datetime.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -14,7 +14,8 @@ if TYPE_CHECKING:
 @dataclass(frozen=True)
 class AssignmentSource:
     source_name: str
     integration_id: int
-    queued: datetime = timezone.now()
+    # default_factory ensures each instance gets a fresh timestamp at creation time,
+    # not the stale module-import-time value that a bare `timezone.now()` default would produce.
+    queued: datetime = field(default_factory=timezone.now)
 
     @classmethod

🤖 Grapple PR auto-fix • critical • Review this diff before applying

if assignment_source
else None,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (18 lines, 1 file) — review recommended)

Celery's default JSON serializer cannot serialize datetime objects. assignment_source.to_dict() calls dataclasses.asdict() which returns the queued field as a datetime object. Passing this dict to sync_assignee_outbound.apply_async(kwargs={...}) will raise kombu.exceptions.EncodeError at task dispatch time, causing the outbound sync Celery task to never be queued. This silently drops all outbound sync events whenever an AssignmentSource is present (i.e., for all inbound-triggered syncs).

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-import dataclasses
+import dataclasses
+from datetime import datetime, timezone
 from typing import Any
 
 
@@ -20,9 +21,22 @@ class AssignmentSource:
     def to_dict(self) -> dict[str, Any]:
-        return dataclasses.asdict(self)
+        d = dataclasses.asdict(self)
+        if isinstance(d.get("queued"), datetime):
+            d["queued"] = d["queued"].isoformat()
+        return d
+
+    @classmethod
+    def from_dict(cls, d: dict[str, Any]) -> AssignmentSource:
+        queued = d.get("queued")
+        if isinstance(queued, str):
+            queued = datetime.fromisoformat(queued)
+        return cls(
+            integration_name=d["integration_name"],
+            queued=queued,
+        )

🤖 Grapple PR auto-fix • critical • Review this diff before applying

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (15 lines, 1 file) — review recommended)

from_dict() uses cls(**input_dict) to construct the dataclass. When the queued field arrives as a string (after JSON round-trip through Celery), the frozen dataclass is constructed with queued: str instead of queued: datetime. The except (ValueError, TypeError) block does not catch this — it silently succeeds, returning an invalid object. While this doesn't directly bypass cycle prevention today (since queued isn't used in the comparison), it represents a silent type violation that could be exploited if queued is later used for security-relevant logic (e.g., replay attack prevention based on timestamp freshness).

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,35 +1,47 @@
 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()
 
     @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)
+            # After JSON round-trips (e.g. through Celery), `queued` arrives as
+            # an ISO 8601 string. Python dataclasses do not enforce types at
+            # runtime, so cls(**input_dict) would silently construct an
+            # AssignmentSource with queued: str instead of queued: datetime.
+            # Any subsequent timestamp comparison (e.g. replay-attack prevention)
+            # would then raise TypeError at the callsite rather than here.
+            # We normalise the field before construction so the invariant
+            # queued: datetime is always upheld.
+            coerced = dict(input_dict)
+            if "queued" in coerced and not isinstance(coerced["queued"], datetime):
+                coerced["queued"] = datetime.fromisoformat(str(coerced["queued"]))
+            return cls(**coerced)
         except (ValueError, TypeError):
+            # Catches malformed ISO strings (ValueError from fromisoformat) as
+            # well as unexpected keyword arguments (TypeError from cls(**coerced)).
             return None

🤖 Grapple PR auto-fix • critical • Review this diff before applying

@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (12 lines, 1 file) — review recommended)

from_dict() catches (ValueError, TypeError) but not KeyError. If an unexpected exception type occurs (e.g., from a future field addition or custom validator), errors will propagate uncaught. More critically, from_dict({}) returns None correctly (TypeError from missing required args), but from_dict({'source_name': 'x', 'integration_id': 1, 'unexpected_key': 'y'}) will also raise TypeError ('unexpected keyword argument') and return None — this means forward-compatibility with new fields is silently broken rather than gracefully handled.

--- a/src/sentry/integrations/services/assignment_source.py
+++ b/src/sentry/integrations/services/assignment_source.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, fields
 from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
@@ -29,6 +29,11 @@ class AssignmentSource:
 
     @classmethod
     def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
+        # Filter to only known fields so that serialized AssignmentSource objects
+        # from newer code versions (with additional fields) can still be deserialized
+        # by older workers. Silently dropping unknown fields is preferable to returning
+        # None, which would disable cycle prevention entirely.
+        known_fields = {f.name for f in fields(cls)}
+        filtered = {k: v for k, v in input_dict.items() if k in known_fields}
         try:
-            return cls(**input_dict)
-        except (ValueError, TypeError):
+            return cls(**filtered)
+        except (ValueError, TypeError, KeyError):
             return None

🤖 Grapple PR auto-fix • minor • Review this diff before applying

@Frawatson

Copy link
Copy Markdown
Owner Author

@grapple-pr review

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR introduces an AssignmentSource dataclass to track the origin of issue assignments, allowing the system to prevent sync cycles when an assignment originates from an integration and would otherwise be propagated back to the same integration.

Changes: 7 files (+212 / -15 lines)

  • 🟢 2 files added: src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
  • 🟡 5 files modified: src/sentry/integrations/mixins/issues.py, src/sentry/integrations/tasks/sync_assignee_outbound.py, src/sentry/integrations/utils/sync.py, src/sentry/models/groupassignee.py, tests/sentry/models/test_groupassignee.py

Resolves: #7 (GitHub OAuth Security Enhancement)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 7 files"/]
  f1["🟡 issues.py +18/-3"]
  pr_0 --> f1
  f2["🟢 assignment_source.py +35/-0"]
  pr_0 --> f2
  f3["🟡 sync_assignee_outbound.py +16/-3"]
  pr_0 --> f3
  f4["🟡 sync.py +25/-4"]
  pr_0 --> f4
  f5["🟡 groupassignee.py +9/-2"]
  pr_0 --> f5
  f6["🟢 test_assignment_source.py +38/-0"]
  pr_0 --> f6
  f7["🟡 test_groupassignee.py +71/-3"]
  pr_0 --> f7
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

3 critical issues found — please address before merging.

Severity Count
🔴 Critical 3
🟠 Major 1
🟡 Minor 1
🔵 Info 3
Review details
  • Reviewed at: 2026-04-13T01:01:57.964Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 51.1s
  • Low-confidence findings suppressed: 1
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Mutable Default at Class Definition Time (confidence: 100%)

timezone.now() is evaluated once at class-definition time (module import), not per-instance. Every AssignmentSource created without an explicit queued argument will share the exact same datetime value — the timestamp from when the module was first imported. This defeats the purpose of recording when the assignment was queued.

Evidence:

  • This is a frozen dataclass with queued: datetime = timezone.now(). Python evaluates default values for dataclass fields at class definition time, not at instantiation time.
  • from_integration does not pass queued, so every instance created via that factory will have the same stale timestamp.
  • The intent spec explicitly calls this out as 'almost certainly a bug'.
  • Additionally, to_dict() serializes queued (a datetime object) into the dict that becomes a Celery task kwarg. Depending on the Celery serializer (commonly JSON), datetime is not JSON-serializable, which will cause the apply_async call to fail at runtime.

Agent: logic

**kwargs,
):
"""
Propagate a sentry issue's status to a linked issue's status.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Wrong Method Signature Modified (confidence: 100%)

The assignment_source parameter was added to sync_status_outbound instead of sync_assignee_outbound. The abstract method sync_assignee_outbound (line ~400) is left unchanged, yet the Celery task at sync_assignee_outbound.py:62 passes assignment_source=parsed_assignment_source to installation.sync_assignee_outbound(). Since the abstract method and its concrete implementations don't accept this keyword, this will raise TypeError at runtime whenever a non-None assignment_source is present.

Evidence:

  • The diff shows sync_status_outbound gaining assignment_source: AssignmentSource | None = None (line 415), but sync_assignee_outbound (line 397-406) is not modified.
  • In sync_assignee_outbound.py (task), line ~62: installation.sync_assignee_outbound(external_issue, user, assign=assign, assignment_source=parsed_assignment_source) passes the kwarg to a method that doesn't accept it.
  • The intent spec says: 'Concrete integration implementations of sync_assignee_outbound that do not accept **kwargs or the new assignment_source keyword argument will raise TypeError when called from the task.'

Agent: logic

"assign": assign,
"assignment_source_dict": assignment_source.to_dict()
if assignment_source
else None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Celery Serialization Failure (confidence: 90%)

assignment_source.to_dict() includes a queued field that is a datetime object. When this dict is passed as a Celery task kwarg via apply_async, it must be serializable by the configured serializer. If Celery uses JSON (the default), datetime is not JSON-serializable and the task dispatch will raise TypeError: Object of type datetime is not JSON serializable, silently preventing all outbound assignee syncs when an assignment_source is present.

Evidence:

  • to_dict uses asdict(self) which preserves the datetime type for queued.
  • Celery's default JSON serializer cannot handle datetime objects.
  • Even if the serializer supports datetimes (e.g., pickle or msgpack), from_dict on the receiving end calls cls(**input_dict) which would need to reconstruct the datetime from whatever serialized form it arrives in.

Agent: logic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Silent Failure / Bypass of Safety Logic (confidence: 86%)

When from_dict returns None due to deserialization failure (e.g., datetime field not parsing, extra keys, schema mismatch), parsed_assignment_source becomes None and the cycle-prevention check in should_sync is silently bypassed. There is no logging or metric when this happens, making it impossible to diagnose sync cycles caused by serialization issues.

Evidence:

  • from_dict catches ValueError and TypeError and returns None.
  • When parsed_assignment_source is None, should_sync('outbound_assignee', None) will not perform the integration_id comparison and will return True, allowing the sync cycle that this entire feature is meant to prevent.
  • The intent spec calls this out: 'When assignment_source_dict is passed to the Celery task but from_dict returns None, the sync-cycle prevention is silently bypassed with no logging or error.'

Agent: logic

try:
return cls(**input_dict)
except (ValueError, TypeError):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Silent Data Loss on Extra Keys (confidence: 86%)

from_dict uses cls(**input_dict) which means any extra key in the dict (e.g., from a future schema addition or a typo) will cause a TypeError that is caught and returns None, silently discarding valid data. This makes forward compatibility fragile.

Evidence:

  • If a newer version of the code adds a field and an older worker receives the dict with the extra key, from_dict will return None, silently bypassing cycle prevention.
  • The frozen dataclass with cls(**input_dict) is strict about keys.

Agent: logic

class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Code patterns (confidence: 76%)

Dataclass field queued uses timezone.now() as default value, which is evaluated once at class definition time. All instances without an explicit queued argument will share the same timestamp, which is almost certainly a bug. Use field(default_factory=timezone.now) instead.

Evidence:

  • In Python dataclasses, default values are evaluated at class definition time, not instance creation time.
  • The risk section explicitly flags this: 'AssignmentSource default queued field evaluated at class-definition time rather than instance creation time — all instances share the same queued value'
  • This means all AssignmentSource instances created without an explicit queued value will have identical timestamps, breaking any time-based logic.

Agent: style

def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Code patterns (confidence: 76%)

The from_dict() method silently returns None when encountering extra or misspelled keys (e.g., from schema evolution) rather than being lenient. This means a dict from a future schema version with extra fields will fail deserialization and lose the assignment source context.

Evidence:

  • At line 33, cls(**input_dict) will raise TypeError if input_dict contains unexpected keys.
  • The except clause catches this and returns None, silently dropping the source context.
  • The risk section notes: 'from_dict uses cls(**input_dict) which means an extra or misspelled key in the dict (e.g., from a future schema change) will raise TypeError and return None, silently dropping the source context'
  • For backward/forward compatibility, consider filtering to known fields or using a more lenient approach.

Agent: style

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

@abstractmethod

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Documentation (confidence: 76%)

The sync_status_outbound abstract method signature was updated to include an assignment_source parameter, but the method's docstring does not mention this new parameter or its purpose. Concrete implementations may not know how to use it.

Evidence:

  • The abstract method docstring (line 411) only mentions propagating status, not the new assignment_source parameter.
  • Concrete integrations overriding this method need clarity on whether they must accept and use this parameter.
  • The risk section flags: 'The sync_source parameter is added to should_sync on IssueBasicIntegration but the base implementation ignores it — subclasses that override should_sync without adopting the new signature may not benefit from cycle prevention'

Agent: style

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants