""" Core Issue Domain Models Unified, backend-agnostic issue models that serve as the single source of truth for all issue tracking operations. These models combine the best features from various issue tracking systems while maintaining clean domain logic. """ from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import List, Optional, Dict, Any from functools import cached_property class IssueState(Enum): """Universal issue state enumeration with backend mapping support.""" OPEN = "open" CLOSED = "closed" IN_PROGRESS = "in_progress" BLOCKED = "blocked" @classmethod def from_string(cls, state_str: str) -> 'IssueState': """Convert string to IssueState, with fallback handling.""" state_map = { 'open': cls.OPEN, 'closed': cls.CLOSED, 'in_progress': cls.IN_PROGRESS, 'in-progress': cls.IN_PROGRESS, 'progress': cls.IN_PROGRESS, 'blocked': cls.BLOCKED, } return state_map.get(state_str.lower(), cls.OPEN) def to_backend_string(self, backend_type: str) -> str: """Convert to backend-specific string representation.""" if backend_type == 'gitea': return 'open' if self in [self.OPEN, self.IN_PROGRESS, self.BLOCKED] else 'closed' elif backend_type == 'github': return self.value else: return self.value class Priority(Enum): """Universal priority levels.""" LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" @classmethod def from_label(cls, label_name: str) -> Optional['Priority']: """Extract priority from label name.""" if label_name.startswith('priority:'): priority_str = label_name.replace('priority:', '') try: return cls(priority_str) except ValueError: return None return None class IssueType(Enum): """Universal issue types.""" BUG = "bug" FEATURE = "feature" ENHANCEMENT = "enhancement" TASK = "task" DOCUMENTATION = "documentation" QUESTION = "question" @classmethod def from_label(cls, label_name: str) -> Optional['IssueType']: """Extract type from label name.""" try: return cls(label_name.lower()) except ValueError: return None @dataclass(frozen=True) class Label: """Universal label model with backend mapping support.""" name: str color: Optional[str] = None description: Optional[str] = None backend_id: Optional[str] = None # Backend-specific ID for sync @cached_property def category(self) -> str: """Categorize label for efficient filtering.""" if self.name.startswith('priority:'): return 'priority' elif self.name.startswith('status:'): return 'status' elif self.name.startswith('type:'): return 'type' elif self.name in ['bug', 'feature', 'enhancement', 'task', 'documentation', 'question']: return 'type' else: return 'other' @cached_property def priority(self) -> Optional[Priority]: """Extract priority if this is a priority label.""" return Priority.from_label(self.name) @cached_property def issue_type(self) -> Optional[IssueType]: """Extract issue type if this is a type label.""" return IssueType.from_label(self.name) @dataclass(frozen=True) class LabelCategories: """Categorized labels for efficient access.""" priority_labels: List[Label] type_labels: List[Label] status_labels: List[Label] other_labels: List[Label] @cached_property def priority(self) -> Optional[Priority]: """Get the issue priority.""" for label in self.priority_labels: if label.priority: return label.priority return None @cached_property def issue_type(self) -> Optional[IssueType]: """Get the issue type.""" for label in self.type_labels: if label.issue_type: return label.issue_type return None @dataclass class User: """Universal user model.""" id: str # String ID to handle different backend ID types username: str display_name: Optional[str] = None email: Optional[str] = None avatar_url: Optional[str] = None backend_id: Optional[str] = None # Backend-specific ID for sync @dataclass class Milestone: """Universal milestone/project model.""" id: str title: str description: Optional[str] = None state: str = "open" # open, closed due_date: Optional[datetime] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None backend_id: Optional[str] = None @dataclass class Comment: """Universal comment model.""" id: str body: str author: User created_at: datetime updated_at: Optional[datetime] = None backend_id: Optional[str] = None @dataclass class Issue: """ Universal Issue model - single source of truth. Combines the best features from domain and API models while maintaining clean separation between core data and backend-specific details. """ # Core Issue Data id: str # Universal ID (UUID for local, external ID for remotes) number: int # Human-readable number title: str description: str state: IssueState # Metadata created_at: datetime updated_at: datetime closed_at: Optional[datetime] = None # Relationships labels: List[Label] = field(default_factory=list) assignees: List[User] = field(default_factory=list) milestone: Optional[Milestone] = None comments: List[Comment] = field(default_factory=list) # Backend Integration backend_id: Optional[str] = None # Backend-specific ID backend_type: Optional[str] = None # e.g., 'local', 'gitea', 'github' sync_metadata: Dict[str, Any] = field(default_factory=dict) # Performance Optimization _label_categories: Optional[LabelCategories] = field(default=None, init=False) @cached_property def label_categories(self) -> LabelCategories: """Efficiently categorize labels with caching.""" if self._label_categories is None: # Single-pass categorization for performance priority_labels = [] type_labels = [] status_labels = [] other_labels = [] for label in self.labels: if label.category == 'priority': priority_labels.append(label) elif label.category == 'type': type_labels.append(label) elif label.category == 'status': status_labels.append(label) else: other_labels.append(label) self._label_categories = LabelCategories( priority_labels=priority_labels, type_labels=type_labels, status_labels=status_labels, other_labels=other_labels ) return self._label_categories @property def priority(self) -> Optional[Priority]: """Get issue priority from labels.""" return self.label_categories.priority @property def issue_type(self) -> Optional[IssueType]: """Get issue type from labels.""" return self.label_categories.issue_type @property def primary_assignee(self) -> Optional[User]: """Get primary assignee (first one).""" return self.assignees[0] if self.assignees else None def invalidate_cache(self) -> None: """Invalidate cached properties when labels change.""" if hasattr(self, '_label_categories'): object.__setattr__(self, '_label_categories', None) # Domain Logic Methods def close(self, closed_at: Optional[datetime] = None) -> None: """Close the issue with business rule validation.""" if self.state == IssueState.CLOSED: raise ValueError(f"Issue #{self.number} is already closed") self.state = IssueState.CLOSED self.closed_at = closed_at or datetime.now(timezone.utc) self.updated_at = datetime.now(timezone.utc) def reopen(self) -> None: """Reopen the issue with business rule validation.""" if self.state != IssueState.CLOSED: raise ValueError(f"Issue #{self.number} is not closed (current state: {self.state.value})") self.state = IssueState.OPEN self.closed_at = None self.updated_at = datetime.now(timezone.utc) def add_label(self, label: Label) -> None: """Add a label to the issue.""" if label not in self.labels: self.labels.append(label) self.invalidate_cache() self.updated_at = datetime.now(timezone.utc) def remove_label(self, label_name: str) -> bool: """Remove a label by name. Returns True if removed.""" original_count = len(self.labels) self.labels = [label for label in self.labels if label.name != label_name] if len(self.labels) < original_count: self.invalidate_cache() self.updated_at = datetime.now(timezone.utc) return True return False def has_label(self, label_name: str) -> bool: """Check if issue has a specific label.""" return any(label.name == label_name for label in self.labels) def add_assignee(self, user: User) -> None: """Add an assignee to the issue.""" if user not in self.assignees: self.assignees.append(user) self.updated_at = datetime.now(timezone.utc) def remove_assignee(self, user_id: str) -> bool: """Remove an assignee by ID. Returns True if removed.""" original_count = len(self.assignees) self.assignees = [user for user in self.assignees if user.id != user_id] if len(self.assignees) < original_count: self.updated_at = datetime.now(timezone.utc) return True return False def add_comment(self, comment: Comment) -> None: """Add a comment to the issue.""" self.comments.append(comment) self.updated_at = datetime.now(timezone.utc) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization.""" return { 'id': self.id, 'number': self.number, 'title': self.title, 'description': self.description, 'state': self.state.value, 'created_at': self.created_at.isoformat(), 'updated_at': self.updated_at.isoformat(), 'closed_at': self.closed_at.isoformat() if self.closed_at else None, 'labels': [{'name': l.name, 'color': l.color, 'description': l.description} for l in self.labels], 'assignees': [{'id': u.id, 'username': u.username, 'display_name': u.display_name} for u in self.assignees], 'milestone': {'id': self.milestone.id, 'title': self.milestone.title} if self.milestone else None, 'backend_id': self.backend_id, 'backend_type': self.backend_type, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Issue': """Create Issue from dictionary.""" # This would be implemented with proper parsing # Simplified version for now raise NotImplementedError("from_dict implementation needed")