generated from coulomb/repo-seed
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""
|
|
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") |