Skip to content

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

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

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

Conversation

@akshayutture-augment

@akshayutture-augment akshayutture-augment commented Nov 14, 2025

Copy link
Copy Markdown

Test 7

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced assignment synchronization across integrated services with origin tracking to prevent duplicate syncs.
  • Bug Fixes

    • Fixed circular assignment synchronization that could occur between the same integration.

Replicated from ai-code-review-evaluation/sentry-coderabbit#7

@akshayutture-augment

Copy link
Copy Markdown
Author

@coderabbitai review

@akshayutture-augment

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 18, 2025

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Nov 18, 2025

Copy link
Copy Markdown

Walkthrough

This change introduces assignment source tracking to prevent synchronization cycles when integrations exchange issue assignments. A new AssignmentSource dataclass records the origin of assignments, and method signatures across the sync chain are extended to propagate this information, enabling guards that skip syncing when the source matches the target integration.

Changes

Cohort / File(s) Change Summary
Assignment Source Infrastructure
src/sentry/integrations/services/assignment_source.py
New dataclass AssignmentSource with fields source_name, integration_id, and queued (with timezone.now() default). Includes from_integration(), to_dict(), and from_dict() methods for serialization/deserialization.
Integration Sync Mixins
src/sentry/integrations/mixins/issues.py
Updated should_sync signatures in IssueBasicIntegration and IssueSyncIntegration to accept optional sync_source: AssignmentSource | None parameter. Added guard in IssueSyncIntegration.should_sync to return False when sync_source originates from the same integration. Extended sync_status_outbound signature to include optional assignment_source parameter.
Outbound Sync Task
src/sentry/integrations/tasks/sync_assignee_outbound.py
Added optional assignment_source_dict parameter to sync_assignee_outbound(). Parses dict into typed AssignmentSource via from_dict(). Updated should_sync() call and sync invocation to pass parsed assignment_source.
Sync Utilities
src/sentry/integrations/utils/sync.py
Extended sync_group_assignee_outbound() signature to accept optional assignment_source: AssignmentSource | None. Propagates assignment_source through both inbound and outbound sync flows; converts to dict for async task invocation.
GroupAssignee Model
src/sentry/models/groupassignee.py
Added optional assignment_source: AssignmentSource | None = None parameter to GroupAssigneeManager.assign() and GroupAssigneeManager.deassign() methods. Threads assignment_source through to outbound sync calls.
Tests
tests/sentry/integrations/services/test_assignment_source.py
New test module covering AssignmentSource.from_dict() (empty dict, invalid data, valid data scenarios), to_dict() serialization, and queued field presence.
Model Tests
tests/sentry/models/test_groupassignee.py
Updated tests to verify assignment_source parameter propagation through assign/deassign paths. Tests assert outbound sync calls include assignment_source and validate that matching source integrations skip outbound synchronization.

Sequence Diagram

sequenceDiagram
    participant Client
    participant GroupAssignee
    participant SyncUtil
    participant SyncTask
    participant Integration

    Client->>GroupAssignee: assign(group, user, assignment_source)
    activate GroupAssignee
    
    GroupAssignee->>GroupAssignee: Create/update assignee
    GroupAssignee->>SyncUtil: sync_group_assignee_outbound(group, user, assignment_source)
    deactivate GroupAssignee
    
    activate SyncUtil
    SyncUtil->>SyncTask: Enqueue sync_assignee_outbound(external_issue_id, user_id, assign=True, assignment_source_dict)
    deactivate SyncUtil
    
    activate SyncTask
    SyncTask->>SyncTask: Parse assignment_source_dict to AssignmentSource
    SyncTask->>Integration: should_sync("outbound_assignee", parsed_assignment_source)
    
    alt Source matches integration ID
        Integration-->>SyncTask: False (prevent cycle)
        SyncTask->>SyncTask: Skip sync
    else Source is different or None
        Integration-->>SyncTask: True
        SyncTask->>Integration: sync_assignee_outbound(external_issue, user, assignment_source)
        Integration->>Integration: Update external system
    end
    deactivate SyncTask
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Method signature consistency: Five files updated with similar assignment_source parameter additions following a cohesive pattern, reducing per-file review complexity
  • New serialization logic: AssignmentSource.from_dict() and to_dict() are straightforward but require verification of edge cases (None handling, invalid input)
  • Guard condition logic: The cycle-prevention check in IssueSyncIntegration.should_sync() warrants careful review to ensure it correctly compares integration IDs
  • Test coverage: Verify that new tests adequately cover from_dict() with various inputs (empty, invalid, valid) and that integration tests validate sync skipping behavior

Pre-merge checks and finishing touches

❌ 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%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ecosystem): Implement cross-system issue synchronization' accurately reflects the main changes in the pull request, which involve adding assignment source tracking and preventing sync cycles across multiple integration files.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ecosystem-sync-integration-after

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

🧹 Nitpick comments (3)
tests/sentry/integrations/services/test_assignment_source.py (1)

8-38: Good coverage of from_dict/to_dict, with minor naming nits

The tests nicely exercise the happy path and two failure modes for from_dict, plus basic to_dict shape (including queued). Two small nits: the method name test_from_dict_empty_array deals with an empty dict rather than an array, and test_from_dict_inalid_data has a typo in “invalid”. Renaming these for clarity would make the test suite a bit easier to scan, but behaviour-wise everything looks solid.

src/sentry/integrations/utils/sync.py (1)

94-116: Inbound assign/deassign now tagging source correctly

Passing assignment_source=AssignmentSource.from_integration(integration) through both the deassign and assign paths wires the inbound webhook’s originating integration into the later outbound checks, which is exactly what’s needed to enable the cycle guard. The usage is type-consistent with from_integration (RpcIntegration) and GroupAssignee.deassign/assign’s new parameter. If you care about micro-allocations, you could compute the AssignmentSource once per call and reuse it for all affected groups, but functionally this is sound.

src/sentry/integrations/mixins/issues.py (1)

65-67: Base should_sync now takes sync_source but ignores it (Ruff ARG002)

The new sync_source parameter on IssueBasicIntegration.should_sync keeps the base API aligned with IssueSyncIntegration, but both attribute and sync_source are unused here, which is what Ruff is flagging. If Ruff is enforced in CI, consider marking these as intentionally unused (e.g., _attribute, _sync_source) or adding a del attribute, sync_source to quiet ARG002.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 19c92a4 and f9919d7.

📒 Files selected for processing (7)
  • src/sentry/integrations/mixins/issues.py (4 hunks)
  • src/sentry/integrations/services/assignment_source.py (1 hunks)
  • src/sentry/integrations/tasks/sync_assignee_outbound.py (3 hunks)
  • src/sentry/integrations/utils/sync.py (4 hunks)
  • src/sentry/models/groupassignee.py (5 hunks)
  • tests/sentry/integrations/services/test_assignment_source.py (1 hunks)
  • tests/sentry/models/test_groupassignee.py (3 hunks)
🧰 Additional context used
🪛 Ruff (0.14.5)
src/sentry/integrations/mixins/issues.py

66-66: Unused method argument: attribute

(ARG002)


66-66: Unused method argument: sync_source

(ARG002)

src/sentry/integrations/services/assignment_source.py

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

(RUF009)

🔇 Additional comments (6)
src/sentry/integrations/utils/sync.py (1)

123-144: Outbound helper correctly threads optional AssignmentSource into the task

The updated sync_group_assignee_outbound signature and its assignment_source_dict=assignment_source.to_dict() if assignment_source else None handoff to the task look correct and remain backwards-compatible for existing callers that don’t pass assignment_source. This keeps the celery payload JSON-serializable while preserving enough structure for AssignmentSource.from_dict downstream.

src/sentry/integrations/mixins/issues.py (2)

382-394: Cycle guard in IssueSyncIntegration.should_sync is correctly scoped

The updated should_sync implementation first validates key and self.org_integration, then rejects sync when sync_source.integration_id == self.org_integration.integration_id. That prevents an integration from re‑syncing its own outbound changes while still allowing other integrations to propagate them, which matches the intended “no cycles, but do fan-out” behaviour. The final config lookup remains unchanged, so existing feature flags still control whether sync happens.


411-418: New assignment_source param on sync_status_outbound—ensure all implementations are updated

Extending sync_status_outbound with assignment_source: AssignmentSource | None = None at the abstract base level is reasonable, but it does mean every concrete IssueSyncIntegration implementation must now accept this parameter. Please double-check (e.g., via a search for def sync_status_outbound) that all subclasses’ signatures are updated to include assignment_source and **kwargs, so callers passing the new kwarg don’t hit TypeError: got an unexpected keyword argument 'assignment_source'.

src/sentry/models/groupassignee.py (1)

130-241: Assign/deassign correctly thread assignment_source into outbound sync without breaking callers

Adding the optional assignment_source parameter to GroupAssigneeManager.assign and deassign, and forwarding it into sync_group_assignee_outbound, cleanly exposes the new metadata while keeping existing call sites intact (all prior uses can still omit the new argument). The feature/gating logic and side-effects (subscriptions, activity, metrics, history) remain unchanged, so the only behavioural difference is that outbound syncs now carry provenance when provided.

tests/sentry/models/test_groupassignee.py (1)

7-237: Tests correctly assert new assignment_source wiring and cycle-prevention behaviour

The updated outbound assign/unassign tests and the new “matching source integration” test line up well with the new API: they verify that default flows pass assignment_source=None, that the manager persists assignments and activities as before, and that when assignment_source is derived from the same integration, sync_assignee_outbound on ExampleIntegration is not called. This gives good end-to-end coverage of the provenance threading and cycle guard without overfitting to implementation details.

src/sentry/integrations/tasks/sync_assignee_outbound.py (1)

1-61: Task-level wiring of assignment_source looks correct; confirm install implementations accept the new args

The task’s new assignment_source_dict parameter is threaded cleanly: it’s parsed via AssignmentSource.from_dict, passed into installation.should_sync("outbound_assignee", parsed_assignment_source), and then into installation.sync_assignee_outbound(..., assignment_source=parsed_assignment_source). Existing calls without this extra kwarg stay valid thanks to the default, and malformed dicts safely degrade to None. Please double-check that all integration installations providing should_sync and sync_assignee_outbound now accept the extra sync_source / assignment_source parameters (ideally with a **kwargs fallback) so these new calls can’t trigger TypeError at runtime.

Comment on lines +14 to +35
@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)
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 | 🟠 Major

🧩 Analysis chain

Fix dataclass default for queued to avoid a shared timestamp; consider making from_dict more robust

Right now queued: datetime = timezone.now() is evaluated once at import, so every AssignmentSource instance created without an explicit queued will share the same timestamp. That’s almost certainly not what you want for a “queued” field and is what Ruff’s RUF009 is warning about. You can make this per-instance by using a default factory:

-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
@@
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)

Separately, from_dict currently does a raw cls(**input_dict), which will return None if any extra or malformed keys are present. If this struct might cross version boundaries, you may want to pre-filter to known keys ({"source_name", "integration_id", "queued"}) before constructing, so older or newer producers don’t break deserialization just because of an unexpected field.


I'll verify the review comment by inspecting the file, checking usage patterns, and confirming the technical accuracy of the suggestions.Now let me verify the file exists and check the actual imports and usage patterns:Now let me check the actual file and verify implementation details:

<


🏁 Script executed:

#!/bin/bash
# Check if the file exists and inspect its current state
if [ -f "src/sentry/integrations/services/assignment_source.py" ]; then
  echo "=== File exists, showing content ===" 
  cat "src/sentry/integrations/services/assignment_source.py"
else
  echo "File not found at expected path; searching for it..."
  find . -name "assignment_source.py" -type f 2>/dev/null | head -5
fi

Length of output: 1017


🏁 Script executed:

#!/bin/bash
# Search for usages of AssignmentSource to understand from_dict behavior
echo "=== Searching for AssignmentSource usages ===" 
rg -n "AssignmentSource" --type py -A 3 -B 1 | head -60

Length of output: 4932


🏁 Script executed:

#!/bin/bash
# Check if RUF009 is configured to be raised in the codebase
echo "=== Checking for ruff configuration ===" 
if [ -f "pyproject.toml" ]; then
  grep -A 20 "\[tool.ruff" pyproject.toml | grep -E "(RUF009|RUF008|extend-immutable-calls)" | head -20
fi
if [ -f ".ruff.toml" ]; then
  cat ".ruff.toml"
fi
if [ -f "ruff.toml" ]; then
  cat "ruff.toml"
fi

Length of output: 936


Let me check the test file to understand the exact expectations for from_dict:


🏁 Script executed:

#!/bin/bash
# Inspect the full test file to understand from_dict behavior
cat "tests/sentry/integrations/services/test_assignment_source.py"

Length of output: 1140


🏁 Script executed:

#!/bin/bash
# Check what's imported in assignment_source.py specifically
head -20 "src/sentry/integrations/services/assignment_source.py"

Length of output: 477


Fix dataclass default for queued to use per-instance factory

The queued: datetime = timezone.now() default is evaluated once at module import, so every AssignmentSource instance created without an explicit queued value shares the same timestamp. This is a semantic bug and is what Ruff's RUF009 warns about. Fix it by using a default factory:

-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
@@
-    queued: datetime = timezone.now()
+    queued: datetime = field(default_factory=timezone.now)

Separately, from_dict currently rejects any dict with unexpected keys or missing required fields by catching TypeError and returning None. If this struct crosses version boundaries (e.g., older producers sending unknown fields), consider pre-filtering to known keys ({"source_name", "integration_id", "queued"}) before unpacking to improve forward/backward compatibility—though the current behavior is deliberate per the tests.

📝 Committable suggestion

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

Suggested change
@dataclass(frozen=True)
class AssignmentSource:
source_name: str
integration_id: int
queued: datetime = timezone.now()
@classmethod
def from_integration(cls, integration: Integration | RpcIntegration) -> AssignmentSource:
return AssignmentSource(
source_name=integration.name,
integration_id=integration.id,
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):
return None
from dataclasses import asdict, dataclass, field
@dataclass(frozen=True)
class AssignmentSource:
source_name: str
integration_id: int
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)
@classmethod
def from_dict(cls, input_dict: dict[str, Any]) -> AssignmentSource | None:
try:
return cls(**input_dict)
except (ValueError, TypeError):
return None
🧰 Tools
🪛 Ruff (0.14.5)

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

(RUF009)

🤖 Prompt for AI Agents
In src/sentry/integrations/services/assignment_source.py around lines 14 to 35,
the dataclass uses queued: datetime = timezone.now() which evaluates once at
import and must be changed to use a per-instance factory (use dataclasses.field
with default_factory=timezone.now) so each AssignmentSource gets a fresh
timestamp; additionally, if you want forward/backward compatibility for
from_dict, pre-filter input_dict to only the known keys {"source_name",
"integration_id", "queued"} before unpacking to avoid rejecting dicts with extra
fields (keep the existing exception handling for missing/invalid types if
desired).

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