fix(ecosystem): Breaks issue sync cycles - #1
Conversation
📝 WalkthroughWalkthroughThis PR adds assignment source tracking to prevent sync cycles in Sentry's integration system. When an integration initiates an assignee change, that context is captured and propagated through the entire assignment flow so that outbound sync back to the same integration can be skipped, eliminating loops. ChangesAssignment Source Cycle Prevention
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/sentry/integrations/services/assignment_source.py`:
- Around line 14-18: The dataclass AssignmentSource defines queued: datetime =
timezone.now() which evaluates once at import time causing all instances to
share the same timestamp; change queued to use dataclasses.field with a
default_factory that calls timezone.now (i.e. queued: datetime =
field(default_factory=timezone.now)) so each new AssignmentSource gets a fresh
timestamp, and import field from dataclasses if not already imported.
- Around line 27-35: AssignmentSource.from_dict currently blindly unpacks
input_dict into the dataclass which can leave integration_id as a non-int for
future/untrusted callers; change from_dict in AssignmentSource to defensively
validate/coerce integration_id (e.g., if "integration_id" in input_dict then
attempt int(input_dict["integration_id"]) or return None on failure) before
calling cls(**input_dict) so IssueSyncIntegration.should_sync (which expects int
equality) always sees an int; keep to_dict unchanged but ensure from_dict
normalizes types and returns None on invalid values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6f27c077-d404-4acc-a4eb-15fa0fde0fa2
📒 Files selected for processing (7)
src/sentry/integrations/mixins/issues.pysrc/sentry/integrations/services/assignment_source.pysrc/sentry/integrations/tasks/sync_assignee_outbound.pysrc/sentry/integrations/utils/sync.pysrc/sentry/models/groupassignee.pytests/sentry/integrations/services/test_assignment_source.pytests/sentry/models/test_groupassignee.py
| @dataclass(frozen=True) | ||
| class AssignmentSource: | ||
| source_name: str | ||
| integration_id: int | ||
| queued: datetime = timezone.now() |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python - <<'PY'
from dataclasses import dataclass, field
from datetime import datetime
import time
def now():
return datetime.utcnow()
`@dataclass`(frozen=True)
class Broken:
queued: datetime = now()
`@dataclass`(frozen=True)
class Fixed:
queued: datetime = field(default_factory=now)
a = Broken()
time.sleep(1)
b = Broken()
print("broken timestamps equal:", a.queued == b.queued, a.queued, b.queued)
c = Fixed()
time.sleep(1)
d = Fixed()
print("fixed timestamps equal:", c.queued == d.queued, c.queued, d.queued)
PYRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 272
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/sentry/integrations/services/assignment_source.py"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE"
exit 1
fi
nl -ba "$FILE" | sed -n '1,120p'Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 147
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/sentry/integrations/services/assignment_source.py"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE" >&2
exit 1
fi
# Print with line numbers around the reported location (14-18)
python3 - <<'PY'
path = "src/sentry/integrations/services/assignment_source.py"
start, end = 1, 80
with open(path, "r", encoding="utf-8") as f:
for i, line in enumerate(f, start=1):
if start <= i <= end:
print(f"{i:4d}:{line.rstrip()}")
if i > end:
break
PYRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 1260
Use field(default_factory=timezone.now) for AssignmentSource.queued.
In src/sentry/integrations/services/assignment_source.py (around lines 14-18), queued: datetime = timezone.now() is evaluated once at class definition time, so all instances created without an explicit queued share the same timestamp. Use default_factory instead.
Suggested fix
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
...
- queued: datetime = timezone.now()
+ queued: datetime = field(default_factory=timezone.now)🧰 Tools
🪛 Ruff (0.15.15)
[warning] 18-18: Do not perform function call timezone.now in dataclass defaults
(RUF009)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/sentry/integrations/services/assignment_source.py` around lines 14 - 18,
The dataclass AssignmentSource defines queued: datetime = timezone.now() which
evaluates once at import time causing all instances to share the same timestamp;
change queued to use dataclasses.field with a default_factory that calls
timezone.now (i.e. queued: datetime = field(default_factory=timezone.now)) so
each new AssignmentSource gets a fresh timestamp, and import field from
dataclasses if not already imported.
| def to_dict(self) -> dict[str, Any]: | ||
| return asdict(self) | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None: | ||
| try: | ||
| return cls(**input_dict) | ||
| except (ValueError, TypeError): | ||
| return None |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python - <<'PY'
from dataclasses import dataclass
`@dataclass`(frozen=True)
class AssignmentSource:
source_name: str
integration_id: int
bad = AssignmentSource(**{"source_name": "jira", "integration_id": "123"})
print("runtime type:", type(bad.integration_id).__name__)
print("matches int 123:", bad.integration_id == 123)
PYRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 145
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Read the actual AssignmentSource definition + from_dict implementation
FILE="src/sentry/integrations/services/assignment_source.py"
if [ -f "$FILE" ]; then
echo "=== $FILE ==="
nl -ba "$FILE" | sed -n '1,200p'
else
echo "Missing file: $FILE" >&2
exit 1
fi
# 2) Find where IssueSyncIntegration.should_sync compares integration_id
# (Search for IssueSyncIntegration and should_sync)
echo "=== Search IssueSyncIntegration ==="
rg -n "class IssueSyncIntegration|def should_sync" -S src/sentry || true
# 3) Find comparisons involving integration_id in sync-related code
echo "=== Search integration_id comparisons ==="
rg -n "integration_id" -S src/sentry/integrations || trueRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 227
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/sentry/integrations/services/assignment_source.py"
echo "=== $FILE (first 200 lines with numbers) ==="
if [ -f "$FILE" ]; then
cat -n "$FILE" | sed -n '1,200p'
else
echo "Missing file: $FILE" >&2
exit 1
fi
echo "=== rg: class IssueSyncIntegration / def should_sync ==="
rg -n "class IssueSyncIntegration|def should_sync" -S src/sentry || true
echo "=== rg: integration_id comparisons in integrations/services ==="
rg -n "integration_id" -S src/sentry/integrations/services || true
echo "=== rg: integration_id comparisons in integrations ==="
rg -n "integration_id" -S src/sentry/integrations | head -n 200 || trueRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 35789
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== rg: AssignmentSource.from_dict usage ==="
rg -n "AssignmentSource\.from_dict|\.from_dict\(" -S src/sentry/integrations/mixins/issues.py src/sentry || true
echo "=== show IssueSyncIntegration.should_sync implementation ==="
# Print the relevant region around should_sync and any uses of sync_source/integration_id comparisons
rg -n "class IssueSyncIntegration|def should_sync" -n src/sentry/integrations/mixins/issues.py
sed -n '330,470p' src/sentry/integrations/mixins/issues.py
echo "=== also inspect earlier should_sync (mixin base) ==="
sed -n '1,120p' src/sentry/integrations/mixins/issues.pyRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 11356
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Show sync_assignee_outbound.py around AssignmentSource.from_dict ==="
sed -n '1,140p' src/sentry/integrations/tasks/sync_assignee_outbound.py
echo "=== rg assignment_source_dict ==="
rg -n "assignment_source_dict" -S src/sentry/integrations || true
echo "=== rg: should_sync( and sync_source= ==="
rg -n "should_sync\\(|sync_source=" -S src/sentry/integrations/mixins/issues.py src/sentry/integrations || true
echo "=== rg: from_dict(assignment_source" # narrow search
rg -n "AssignmentSource\\.from_dict\\(" -S src/sentry/integrations || trueRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 4775
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Show src/sentry/integrations/utils/sync.py around assignment_source_dict ==="
sed -n '1,220p' src/sentry/integrations/utils/sync.py
echo "=== Search other task usage of AssignmentSource.from_dict(assignment_source_dict) ==="
rg -n "AssignmentSource\\.from_dict\\(assignment_source_dict\\)" -S src/sentry/integrations/tasks || true
echo "=== Inspect sync_status_outbound task for AssignmentSource.from_dict usage ==="
sed -n '1,140p' src/sentry/integrations/tasks/sync_status_outbound.pyRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 7529
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== rg: AssignmentSource.from_dict( ==="
rg -n "AssignmentSource\.from_dict\(" -S src/sentry || true
echo "=== rg: AssignmentSource\.to_dict\(" # where dict payloads are created
rg -n "AssignmentSource\.to_dict\(" -S src/sentry || true
echo "=== rg: assignment_source_dict building ==="
rg -n "assignment_source_dict" -S src/sentry/integrations || true
echo "=== rg: from_integration( ==="
rg -n "AssignmentSource\.from_integration" -S src/sentry/integrations || trueRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 1038
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Search Celery/task serializer configuration ==="
rg -n "task_serializer|accept_content|serializer|json_serializer|pickle|dumps\\(|loads\\(" -S src/sentry | head -n 200
echo "=== Search for custom serializer that might stringify ints/datetimes ==="
rg -n "orjson|ujson|msgpack|pickle|kombu|celery" -S src/sentry | head -n 200Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 40162
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== rg Celery TASK_SERIALIZER / ACCEPT_CONTENT ==="
rg -n "CELERY_(TASK_SERIALIZER|ACCEPT_CONTENT|RESULT_SERIALIZER|SERIALIZER)|task_serializer|accept_content" -S . | head -n 200 || true
echo "=== rg sentry/celery config ==="
rg -n "from celery|Celery\\(" -S src/sentry/celery src/sentry | head -n 200 || true
echo "=== list likely settings files ==="
fd -t f -e py -e toml -e ini -e yml -e yaml -e cfg -a "settings*.py" . 2>/dev/null || trueRepository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407
Length of output: 4189
Downgrade severity: current code path doesn’t stringify integration_id
AssignmentSource.from_dict() constructs the dataclass via cls(**input_dict) with no runtime type validation, and IssueSyncIntegration.should_sync() uses direct int equality. However, along the only call path (AssignmentSource.to_dict() → sync_assignee_outbound.apply_async(... assignment_source_dict=...) → AssignmentSource.from_dict()), Celery task kwargs are serialized with CELERY_TASK_SERIALIZER="pickle" and only "pickle" is accepted, so integration_id should remain an int (not a "123" string). The proposed “string bypasses the loop guard” scenario doesn’t appear to occur here; at most, this is a defensive hardening opportunity for future/untrusted callers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/sentry/integrations/services/assignment_source.py` around lines 27 - 35,
AssignmentSource.from_dict currently blindly unpacks input_dict into the
dataclass which can leave integration_id as a non-int for future/untrusted
callers; change from_dict in AssignmentSource to defensively validate/coerce
integration_id (e.g., if "integration_id" in input_dict then attempt
int(input_dict["integration_id"]) or return None on failure) before calling
cls(**input_dict) so IssueSyncIntegration.should_sync (which expects int
equality) always sees an int; keep to_dict unchanged but ensure from_dict
normalizes types and returns None on invalid values.
The problem:
When a Jira issue's
assigneefield is quickly modified multiple times in a row, we end up assigning and resyncing the assignees to Jira, which causes a loop. This is because all inbound integration webhooks that modify an issue assignment generate an outbound update for all issue sync integrations as well.Solution:
This PR adds a new
AssignmentSourcedataclass, which acts as a context carrier when an external webhook triggers an issue assignment change. This is then checked on the outbound sync logic, preventing the change from being propagated to the same integration that initially triggered the change.Summary by CodeRabbit
Release Notes
Bug Fixes
Improvements