generated from coulomb/repo-seed
fix: resolve issue-facade ID mapping bugs and enhance functionality
- Fix Sentinel bug in list command where Click set search params to Sentinel.UNSET - Fix version command by adding explicit version and package_name parameters - Fix test isolation by correcting mock patch targets and datetime objects - Fix critical ID mapping bug: use issue.number consistently instead of mixing with issue.backend_id - Update all comment operations to use issue numbers instead of internal IDs - Ensure issue-facade uses upstream issue numbers directly without local ID confusion - Add comprehensive test coverage with 20 passing tests - Verify core functionality: list, show, close, version, backend management all working - Successfully close issue #166 with proper comment handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
341
issue_tracker/core/models.py
Normal file
341
issue_tracker/core/models.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user