Skip to content

fix(ecosystem): Breaks issue sync cycles - #1

Open
linxia0415 wants to merge 8 commits into
masterfrom
pr-77754
Open

fix(ecosystem): Breaks issue sync cycles#1
linxia0415 wants to merge 8 commits into
masterfrom
pr-77754

Conversation

@linxia0415

@linxia0415 linxia0415 commented Jun 4, 2026

Copy link
Copy Markdown

The problem:

When a Jira issue's assignee field 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 AssignmentSource dataclass, 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

    • Fixed assignment synchronization cycles that could occur when assignments from external issue trackers were re-synced back to the same integration.
  • Improvements

    • Enhanced assignment tracking by propagating origin context through integration workflows, enabling better awareness of assignment sources during synchronization operations.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Assignment Source Cycle Prevention

Layer / File(s) Summary
AssignmentSource data contract and serialization
src/sentry/integrations/services/assignment_source.py, tests/sentry/integrations/services/test_assignment_source.py
Immutable AssignmentSource dataclass captures integration name, id, and queued timestamp. Includes from_integration() factory, to_dict() serialization, and from_dict() safe deserialization. Tests cover empty, invalid, and valid dictionary roundtripping.
Sync decision gate with cycle prevention
src/sentry/integrations/mixins/issues.py
IssueBasicIntegration.should_sync and IssueSyncIntegration.should_sync now accept optional sync_source: AssignmentSource. IssueSyncIntegration adds cycle prevention: blocks outbound sync when source integration matches the current organization integration. sync_status_outbound abstract method adds assignment_source parameter for context propagation.
Model layer assignment threading
src/sentry/models/groupassignee.py
GroupAssigneeManager.assign and GroupAssigneeManager.deassign accept optional assignment_source parameter and pass it through to sync_group_assignee_outbound, establishing the entry point for source tracking in the assignment flow.
Sync utility inbound and outbound propagation
src/sentry/integrations/utils/sync.py
Inbound sync decorates all assignments and deassignments with integration source via AssignmentSource.from_integration(). Outbound sync accepts assignment_source and serializes it into async task payloads for decision-making at the task handler.
Async task handler integration
src/sentry/integrations/tasks/sync_assignee_outbound.py
Task accepts assignment_source_dict, deserializes it into AssignmentSource, and threads it through both the sync decision gate (should_sync) and the sync method invocation to enforce cycle prevention.
Cycle prevention behavior validation
tests/sentry/models/test_groupassignee.py
Existing tests updated to expect assignment_source=None in normal assignment/deassignment sync calls. New test verifies cycle prevention: when assignment originates from an integration (via assignment_source=AssignmentSource.from_integration(...)), outbound sync to that same integration is blocked.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A source rides with each assignee,
Preventing loops so none can flee—
From inbound synced with integration's trace,
Outbound checks and blocks its own place.
Round and round no more, the cycle breaks free! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: introducing a mechanism to prevent issue sync cycles. It's concise, specific, and directly relates to the primary objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr-77754

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 36b01d6 and 9501091.

📒 Files selected for processing (7)
  • src/sentry/integrations/mixins/issues.py
  • src/sentry/integrations/services/assignment_source.py
  • src/sentry/integrations/tasks/sync_assignee_outbound.py
  • src/sentry/integrations/utils/sync.py
  • src/sentry/models/groupassignee.py
  • tests/sentry/integrations/services/test_assignment_source.py
  • tests/sentry/models/test_groupassignee.py

Comment on lines +14 to +18
@dataclass(frozen=True)
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

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

def now():
    return datetime.utcnow()

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

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 272


🏁 Script executed:

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 147


🏁 Script executed:

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 1260


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

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

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

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

(RUF009)

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

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

Comment on lines +27 to +35
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 145


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 35789


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 11356


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 4775


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 7529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 1038


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 40162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

Repository: test-code-tool/sentry__sentry__coderabbit__PR77754__20260407

Length of output: 4189


Downgrade severity: current code path doesn’t stringify integration_id

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

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

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

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