Files
markitect-main/issue-facade/core/models.py
tegwick cb94c92fc0 feat: implement universal issue tracking facade
Add comprehensive issue tracking facade system that provides a unified CLI interface to any issue tracking backend. The facade automatically detects the repository's issue tracker and provides consistent commands across all platforms.

Key features:
- Repository-aware automatic backend detection (GitHub, GitLab, Gitea, local SQLite)
- Unified CLI interface with same commands across all backends
- Plugin architecture for extensible backend support
- Local SQLite backend for offline development
- Gitea backend with full API integration
- Bidirectional synchronization between backends
- Performance-optimized domain models with caching
- Clean architecture with separation of concerns

The facade acts as a "universal remote control" for issue tracking systems, eliminating the need to learn different CLIs for each platform while providing seamless offline capability and cross-platform consistency.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 21:04:43 +02:00

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")