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:
407
issue_tracker/core/interfaces.py
Normal file
407
issue_tracker/core/interfaces.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
Backend Plugin Interfaces
|
||||
|
||||
Defines the contracts that all issue tracking backend plugins must implement.
|
||||
This enables a clean plugin architecture where new backends can be added
|
||||
without modifying core code.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any, Iterator
|
||||
from datetime import datetime
|
||||
|
||||
from .models import Issue, Label, User, Milestone, Comment
|
||||
|
||||
|
||||
class IssueFilter:
|
||||
"""Filter criteria for issue queries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
milestone: Optional[str] = None,
|
||||
created_after: Optional[datetime] = None,
|
||||
created_before: Optional[datetime] = None,
|
||||
updated_after: Optional[datetime] = None,
|
||||
updated_before: Optional[datetime] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = 0
|
||||
):
|
||||
self.state = state
|
||||
self.assignee = assignee
|
||||
self.labels = labels or []
|
||||
self.milestone = milestone
|
||||
self.created_after = created_after
|
||||
self.created_before = created_before
|
||||
self.updated_after = updated_after
|
||||
self.updated_before = updated_before
|
||||
self.search = search
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
|
||||
|
||||
class BackendCapabilities:
|
||||
"""Describes what features a backend supports."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
supports_milestones: bool = True,
|
||||
supports_assignees: bool = True,
|
||||
supports_comments: bool = True,
|
||||
supports_labels: bool = True,
|
||||
supports_search: bool = True,
|
||||
supports_bulk_operations: bool = False,
|
||||
supports_webhooks: bool = False,
|
||||
supports_real_time: bool = False,
|
||||
max_labels_per_issue: Optional[int] = None,
|
||||
max_assignees_per_issue: Optional[int] = None
|
||||
):
|
||||
self.supports_milestones = supports_milestones
|
||||
self.supports_assignees = supports_assignees
|
||||
self.supports_comments = supports_comments
|
||||
self.supports_labels = supports_labels
|
||||
self.supports_search = supports_search
|
||||
self.supports_bulk_operations = supports_bulk_operations
|
||||
self.supports_webhooks = supports_webhooks
|
||||
self.supports_real_time = supports_real_time
|
||||
self.max_labels_per_issue = max_labels_per_issue
|
||||
self.max_assignees_per_issue = max_assignees_per_issue
|
||||
|
||||
|
||||
class IssueBackend(ABC):
|
||||
"""
|
||||
Abstract base class for all issue tracking backends.
|
||||
|
||||
Each backend plugin must implement this interface to provide
|
||||
issue tracking functionality for a specific system.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def backend_type(self) -> str:
|
||||
"""Return the backend type identifier (e.g., 'local', 'gitea', 'github')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def capabilities(self) -> BackendCapabilities:
|
||||
"""Return the capabilities supported by this backend."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def connect(self, config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Connect to the backend using provided configuration.
|
||||
|
||||
Args:
|
||||
config: Backend-specific configuration (URLs, tokens, etc.)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the backend and clean up resources."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test_connection(self) -> bool:
|
||||
"""Test if the backend connection is working."""
|
||||
pass
|
||||
|
||||
# Issue CRUD Operations
|
||||
@abstractmethod
|
||||
def create_issue(self, issue: Issue) -> Issue:
|
||||
"""
|
||||
Create a new issue in the backend.
|
||||
|
||||
Args:
|
||||
issue: Issue to create (id may be None for new issues)
|
||||
|
||||
Returns:
|
||||
Created issue with backend_id populated
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_issue(self, issue_id: str) -> Optional[Issue]:
|
||||
"""
|
||||
Retrieve an issue by its backend ID.
|
||||
|
||||
Args:
|
||||
issue_id: Backend-specific issue ID
|
||||
|
||||
Returns:
|
||||
Issue if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_issue_by_number(self, number: int) -> Optional[Issue]:
|
||||
"""
|
||||
Retrieve an issue by its human-readable number.
|
||||
|
||||
Args:
|
||||
number: Issue number
|
||||
|
||||
Returns:
|
||||
Issue if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_issue(self, issue: Issue) -> Issue:
|
||||
"""
|
||||
Update an existing issue in the backend.
|
||||
|
||||
Args:
|
||||
issue: Issue with modifications
|
||||
|
||||
Returns:
|
||||
Updated issue
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_issue(self, issue_id: str) -> bool:
|
||||
"""
|
||||
Delete an issue from the backend.
|
||||
|
||||
Args:
|
||||
issue_id: Backend-specific issue ID
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_issues(self, filter_criteria: Optional[IssueFilter] = None) -> List[Issue]:
|
||||
"""
|
||||
List issues matching filter criteria.
|
||||
|
||||
Args:
|
||||
filter_criteria: Optional filter to apply
|
||||
|
||||
Returns:
|
||||
List of matching issues
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""
|
||||
Search issues using backend-specific query syntax.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching issues
|
||||
"""
|
||||
pass
|
||||
|
||||
# Label Operations
|
||||
@abstractmethod
|
||||
def create_label(self, label: Label) -> Label:
|
||||
"""Create a new label."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_labels(self) -> List[Label]:
|
||||
"""Get all available labels."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_label(self, label: Label) -> Label:
|
||||
"""Update an existing label."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_label(self, label_name: str) -> bool:
|
||||
"""Delete a label."""
|
||||
pass
|
||||
|
||||
# User Operations
|
||||
@abstractmethod
|
||||
def get_users(self) -> List[User]:
|
||||
"""Get all available users."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_user(self, user_id: str) -> Optional[User]:
|
||||
"""Get a specific user by ID."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_users(self, query: str) -> List[User]:
|
||||
"""Search for users."""
|
||||
pass
|
||||
|
||||
# Milestone Operations
|
||||
@abstractmethod
|
||||
def create_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Create a new milestone."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_milestones(self) -> List[Milestone]:
|
||||
"""Get all milestones."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Update a milestone."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_milestone(self, milestone_id: str) -> bool:
|
||||
"""Delete a milestone."""
|
||||
pass
|
||||
|
||||
# Comment Operations
|
||||
@abstractmethod
|
||||
def add_comment(self, issue_id: str, comment: Comment) -> Comment:
|
||||
"""Add a comment to an issue."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_comments(self, issue_id: str) -> List[Comment]:
|
||||
"""Get all comments for an issue."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_comment(self, comment: Comment) -> Comment:
|
||||
"""Update a comment."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_comment(self, comment_id: str) -> bool:
|
||||
"""Delete a comment."""
|
||||
pass
|
||||
|
||||
# Bulk Operations (optional, depends on capabilities)
|
||||
def bulk_update_issues(self, updates: List[Dict[str, Any]]) -> List[Issue]:
|
||||
"""
|
||||
Bulk update multiple issues.
|
||||
|
||||
Args:
|
||||
updates: List of update operations
|
||||
|
||||
Returns:
|
||||
List of updated issues
|
||||
"""
|
||||
if not self.capabilities.supports_bulk_operations:
|
||||
raise NotImplementedError(f"{self.backend_type} backend does not support bulk operations")
|
||||
|
||||
# Default implementation: update one by one
|
||||
results = []
|
||||
for update in updates:
|
||||
issue_id = update['id']
|
||||
issue = self.get_issue(issue_id)
|
||||
if issue:
|
||||
# Apply updates to issue
|
||||
for key, value in update.items():
|
||||
if key != 'id' and hasattr(issue, key):
|
||||
setattr(issue, key, value)
|
||||
updated_issue = self.update_issue(issue)
|
||||
results.append(updated_issue)
|
||||
return results
|
||||
|
||||
# Sync Support
|
||||
def get_last_sync_timestamp(self) -> Optional[datetime]:
|
||||
"""
|
||||
Get the timestamp of the last successful sync.
|
||||
Used for incremental synchronization.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_issues_modified_since(self, timestamp: datetime) -> List[Issue]:
|
||||
"""
|
||||
Get issues modified since a specific timestamp.
|
||||
Used for incremental synchronization.
|
||||
"""
|
||||
filter_criteria = IssueFilter(updated_after=timestamp)
|
||||
return self.list_issues(filter_criteria)
|
||||
|
||||
|
||||
class LocalBackend(IssueBackend):
|
||||
"""
|
||||
Marker interface for local backends.
|
||||
|
||||
Local backends store data locally and can work offline.
|
||||
They serve as the source of truth for synchronization.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class RemoteBackend(IssueBackend):
|
||||
"""
|
||||
Marker interface for remote backends.
|
||||
|
||||
Remote backends connect to external issue tracking systems.
|
||||
They participate in bidirectional synchronization.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SyncableBackend(ABC):
|
||||
"""
|
||||
Interface for backends that support synchronization.
|
||||
|
||||
Backends implementing this interface can participate in
|
||||
bidirectional sync operations.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def prepare_for_sync(self) -> None:
|
||||
"""Prepare backend for sync operation (e.g., create backup)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def finalize_sync(self, success: bool) -> None:
|
||||
"""Finalize sync operation (e.g., commit or rollback)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sync_conflicts(self) -> List[Dict[str, Any]]:
|
||||
"""Get issues that have sync conflicts."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resolve_sync_conflict(self, issue_id: str, resolution: str) -> Issue:
|
||||
"""
|
||||
Resolve a sync conflict.
|
||||
|
||||
Args:
|
||||
issue_id: Issue with conflict
|
||||
resolution: 'local' or 'remote' or 'merge'
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BackendFactory:
|
||||
"""Factory for creating backend instances."""
|
||||
|
||||
_backends: Dict[str, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register_backend(cls, backend_type: str, backend_class: type) -> None:
|
||||
"""Register a backend implementation."""
|
||||
cls._backends[backend_type] = backend_class
|
||||
|
||||
@classmethod
|
||||
def create_backend(cls, backend_type: str) -> IssueBackend:
|
||||
"""Create a backend instance."""
|
||||
if backend_type not in cls._backends:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
return cls._backends[backend_type]()
|
||||
|
||||
@classmethod
|
||||
def get_available_backends(cls) -> List[str]:
|
||||
"""Get list of available backend types."""
|
||||
return list(cls._backends.keys())
|
||||
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")
|
||||
454
issue_tracker/core/repository.py
Normal file
454
issue_tracker/core/repository.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Repository Pattern Implementation
|
||||
|
||||
Provides a high-level repository interface that abstracts backend operations
|
||||
and adds features like caching, transaction support, and business logic.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from .interfaces import IssueBackend, IssueFilter
|
||||
from .models import Issue, Label, User, Milestone, Comment, IssueState
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IssueRepository:
|
||||
"""
|
||||
High-level repository for issue operations.
|
||||
|
||||
Provides a clean interface for issue management with additional features
|
||||
like caching, validation, and business rule enforcement.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: IssueBackend, enable_caching: bool = True):
|
||||
self.backend = backend
|
||||
self.enable_caching = enable_caching
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_timeout = 300 # 5 minutes
|
||||
|
||||
def _cache_key(self, operation: str, *args) -> str:
|
||||
"""Generate cache key for operation."""
|
||||
return f"{operation}:{':'.join(str(arg) for arg in args)}"
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get value from cache if enabled and not expired."""
|
||||
if not self.enable_caching or key not in self._cache:
|
||||
return None
|
||||
|
||||
cached_item = self._cache[key]
|
||||
if datetime.now(timezone.utc).timestamp() - cached_item['timestamp'] > self._cache_timeout:
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
return cached_item['value']
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
"""Set value in cache if enabled."""
|
||||
if self.enable_caching:
|
||||
self._cache[key] = {
|
||||
'value': value,
|
||||
'timestamp': datetime.now(timezone.utc).timestamp()
|
||||
}
|
||||
|
||||
def _invalidate_cache_pattern(self, pattern: str) -> None:
|
||||
"""Invalidate cache entries matching pattern."""
|
||||
if not self.enable_caching:
|
||||
return
|
||||
|
||||
keys_to_remove = [key for key in self._cache.keys() if pattern in key]
|
||||
for key in keys_to_remove:
|
||||
del self._cache[key]
|
||||
|
||||
# Issue Operations
|
||||
def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
description: str = "",
|
||||
labels: Optional[List[str]] = None,
|
||||
assignees: Optional[List[str]] = None,
|
||||
milestone: Optional[str] = None,
|
||||
issue_type: Optional[str] = None,
|
||||
priority: Optional[str] = None
|
||||
) -> Issue:
|
||||
"""
|
||||
Create a new issue with business rule validation.
|
||||
|
||||
Args:
|
||||
title: Issue title (required)
|
||||
description: Issue description
|
||||
labels: List of label names
|
||||
assignees: List of usernames to assign
|
||||
milestone: Milestone title or ID
|
||||
issue_type: Issue type (bug, feature, etc.)
|
||||
priority: Priority level (low, medium, high, critical)
|
||||
|
||||
Returns:
|
||||
Created issue
|
||||
"""
|
||||
# Validation
|
||||
if not title.strip():
|
||||
raise ValueError("Issue title cannot be empty")
|
||||
|
||||
# Build labels
|
||||
issue_labels = []
|
||||
if labels:
|
||||
for label_name in labels:
|
||||
issue_labels.append(Label(name=label_name.strip()))
|
||||
|
||||
# Add type and priority as labels
|
||||
if issue_type:
|
||||
issue_labels.append(Label(name=issue_type))
|
||||
|
||||
if priority:
|
||||
issue_labels.append(Label(name=f'priority:{priority}'))
|
||||
|
||||
# Resolve assignees
|
||||
issue_assignees = []
|
||||
if assignees:
|
||||
for username in assignees:
|
||||
users = self.backend.search_users(username)
|
||||
if users:
|
||||
issue_assignees.append(users[0])
|
||||
else:
|
||||
# Create basic user if not found
|
||||
issue_assignees.append(User(id=username, username=username))
|
||||
|
||||
# Resolve milestone
|
||||
issue_milestone = None
|
||||
if milestone:
|
||||
milestones = self.backend.get_milestones()
|
||||
for m in milestones:
|
||||
if m.title == milestone or m.id == milestone:
|
||||
issue_milestone = m
|
||||
break
|
||||
|
||||
# Create issue
|
||||
now = datetime.now(timezone.utc)
|
||||
issue = Issue(
|
||||
id="", # Will be set by backend
|
||||
number=0, # Will be set by backend
|
||||
title=title.strip(),
|
||||
description=description.strip(),
|
||||
state=IssueState.OPEN,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
labels=issue_labels,
|
||||
assignees=issue_assignees,
|
||||
milestone=issue_milestone
|
||||
)
|
||||
|
||||
created_issue = self.backend.create_issue(issue)
|
||||
|
||||
# Invalidate relevant caches
|
||||
self._invalidate_cache_pattern("list_issues")
|
||||
self._invalidate_cache_pattern("search_issues")
|
||||
|
||||
logger.info(f"Created issue #{created_issue.number}: {created_issue.title}")
|
||||
return created_issue
|
||||
|
||||
def get_issue(self, issue_id: Union[str, int]) -> Optional[Issue]:
|
||||
"""Get issue by ID or number."""
|
||||
if isinstance(issue_id, int):
|
||||
return self.get_issue_by_number(issue_id)
|
||||
|
||||
cache_key = self._cache_key("get_issue", issue_id)
|
||||
cached_issue = self._get_from_cache(cache_key)
|
||||
if cached_issue:
|
||||
return cached_issue
|
||||
|
||||
issue = self.backend.get_issue(str(issue_id))
|
||||
if issue:
|
||||
self._set_cache(cache_key, issue)
|
||||
|
||||
return issue
|
||||
|
||||
def get_issue_by_number(self, number: int) -> Optional[Issue]:
|
||||
"""Get issue by number."""
|
||||
cache_key = self._cache_key("get_issue_by_number", number)
|
||||
cached_issue = self._get_from_cache(cache_key)
|
||||
if cached_issue:
|
||||
return cached_issue
|
||||
|
||||
issue = self.backend.get_issue_by_number(number)
|
||||
if issue:
|
||||
self._set_cache(cache_key, issue)
|
||||
|
||||
return issue
|
||||
|
||||
def update_issue(self, issue: Issue) -> Issue:
|
||||
"""Update issue with validation."""
|
||||
if not issue.title.strip():
|
||||
raise ValueError("Issue title cannot be empty")
|
||||
|
||||
issue.updated_at = datetime.now(timezone.utc)
|
||||
updated_issue = self.backend.update_issue(issue)
|
||||
|
||||
# Invalidate caches
|
||||
self._invalidate_cache_pattern("get_issue")
|
||||
self._invalidate_cache_pattern("list_issues")
|
||||
self._invalidate_cache_pattern("search_issues")
|
||||
|
||||
logger.info(f"Updated issue #{updated_issue.number}: {updated_issue.title}")
|
||||
return updated_issue
|
||||
|
||||
def close_issue(self, issue_id: Union[str, int], comment: Optional[str] = None) -> Issue:
|
||||
"""Close issue with optional comment."""
|
||||
issue = self.get_issue(issue_id)
|
||||
if not issue:
|
||||
raise ValueError(f"Issue {issue_id} not found")
|
||||
|
||||
if issue.state == IssueState.CLOSED:
|
||||
raise ValueError(f"Issue #{issue.number} is already closed")
|
||||
|
||||
issue.close()
|
||||
|
||||
# Add closing comment if provided
|
||||
if comment:
|
||||
self.add_comment(issue.id, comment)
|
||||
|
||||
return self.update_issue(issue)
|
||||
|
||||
def reopen_issue(self, issue_id: Union[str, int], comment: Optional[str] = None) -> Issue:
|
||||
"""Reopen issue with optional comment."""
|
||||
issue = self.get_issue(issue_id)
|
||||
if not issue:
|
||||
raise ValueError(f"Issue {issue_id} not found")
|
||||
|
||||
if issue.state != IssueState.CLOSED:
|
||||
raise ValueError(f"Issue #{issue.number} is not closed")
|
||||
|
||||
issue.reopen()
|
||||
|
||||
# Add reopening comment if provided
|
||||
if comment:
|
||||
self.add_comment(issue.id, comment)
|
||||
|
||||
return self.update_issue(issue)
|
||||
|
||||
def list_issues(
|
||||
self,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
milestone: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None
|
||||
) -> List[Issue]:
|
||||
"""List issues with filtering and caching."""
|
||||
filter_criteria = IssueFilter(
|
||||
state=state,
|
||||
assignee=assignee,
|
||||
labels=labels,
|
||||
milestone=milestone,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Create cache key from filter criteria
|
||||
cache_key = self._cache_key(
|
||||
"list_issues",
|
||||
state or "all",
|
||||
assignee or "any",
|
||||
",".join(labels) if labels else "any",
|
||||
milestone or "any",
|
||||
search or "any",
|
||||
limit or 0,
|
||||
offset or 0
|
||||
)
|
||||
|
||||
cached_issues = self._get_from_cache(cache_key)
|
||||
if cached_issues:
|
||||
return cached_issues
|
||||
|
||||
issues = self.backend.list_issues(filter_criteria)
|
||||
self._set_cache(cache_key, issues)
|
||||
|
||||
return issues
|
||||
|
||||
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""Search issues with caching."""
|
||||
cache_key = self._cache_key("search_issues", query, limit or 0)
|
||||
cached_issues = self._get_from_cache(cache_key)
|
||||
if cached_issues:
|
||||
return cached_issues
|
||||
|
||||
issues = self.backend.search_issues(query, limit)
|
||||
self._set_cache(cache_key, issues)
|
||||
|
||||
return issues
|
||||
|
||||
# Comment Operations
|
||||
def add_comment(self, issue_id: str, comment_text: str, author_username: str = "cli-user") -> Comment:
|
||||
"""Add comment to issue."""
|
||||
if not comment_text.strip():
|
||||
raise ValueError("Comment text cannot be empty")
|
||||
|
||||
# Try to find user
|
||||
users = self.backend.search_users(author_username)
|
||||
if users:
|
||||
author = users[0]
|
||||
else:
|
||||
author = User(id=author_username, username=author_username)
|
||||
|
||||
comment = Comment(
|
||||
id="",
|
||||
body=comment_text.strip(),
|
||||
author=author,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
added_comment = self.backend.add_comment(issue_id, comment)
|
||||
|
||||
# Invalidate issue cache
|
||||
self._invalidate_cache_pattern("get_issue")
|
||||
|
||||
logger.info(f"Added comment to issue {issue_id}")
|
||||
return added_comment
|
||||
|
||||
def get_comments(self, issue_id: str) -> List[Comment]:
|
||||
"""Get comments for issue."""
|
||||
cache_key = self._cache_key("get_comments", issue_id)
|
||||
cached_comments = self._get_from_cache(cache_key)
|
||||
if cached_comments:
|
||||
return cached_comments
|
||||
|
||||
comments = self.backend.get_comments(issue_id)
|
||||
self._set_cache(cache_key, comments)
|
||||
|
||||
return comments
|
||||
|
||||
# Label Operations
|
||||
def get_or_create_label(self, name: str, color: Optional[str] = None, description: Optional[str] = None) -> Label:
|
||||
"""Get existing label or create new one."""
|
||||
existing_labels = self.backend.get_labels()
|
||||
for label in existing_labels:
|
||||
if label.name == name:
|
||||
return label
|
||||
|
||||
# Create new label
|
||||
new_label = Label(name=name, color=color, description=description)
|
||||
return self.backend.create_label(new_label)
|
||||
|
||||
# Statistics and Analytics
|
||||
def get_issue_stats(self) -> Dict[str, Any]:
|
||||
"""Get issue statistics."""
|
||||
cache_key = self._cache_key("get_issue_stats")
|
||||
cached_stats = self._get_from_cache(cache_key)
|
||||
if cached_stats:
|
||||
return cached_stats
|
||||
|
||||
all_issues = self.backend.list_issues()
|
||||
|
||||
stats = {
|
||||
'total': len(all_issues),
|
||||
'open': len([i for i in all_issues if i.state != IssueState.CLOSED]),
|
||||
'closed': len([i for i in all_issues if i.state == IssueState.CLOSED]),
|
||||
'by_state': {},
|
||||
'by_priority': {},
|
||||
'by_type': {},
|
||||
'by_assignee': {},
|
||||
'recent_activity': 0
|
||||
}
|
||||
|
||||
# Count by state
|
||||
for issue in all_issues:
|
||||
state = issue.state.value
|
||||
stats['by_state'][state] = stats['by_state'].get(state, 0) + 1
|
||||
|
||||
# Count by priority and type
|
||||
one_week_ago = datetime.now(timezone.utc).timestamp() - 604800 # 7 days
|
||||
|
||||
for issue in all_issues:
|
||||
# Priority
|
||||
priority = issue.priority
|
||||
if priority:
|
||||
priority_name = priority.value
|
||||
stats['by_priority'][priority_name] = stats['by_priority'].get(priority_name, 0) + 1
|
||||
|
||||
# Type
|
||||
issue_type = issue.issue_type
|
||||
if issue_type:
|
||||
type_name = issue_type.value
|
||||
stats['by_type'][type_name] = stats['by_type'].get(type_name, 0) + 1
|
||||
|
||||
# Assignee
|
||||
if issue.assignees:
|
||||
for assignee in issue.assignees:
|
||||
username = assignee.username
|
||||
stats['by_assignee'][username] = stats['by_assignee'].get(username, 0) + 1
|
||||
|
||||
# Recent activity
|
||||
if issue.updated_at.timestamp() > one_week_ago:
|
||||
stats['recent_activity'] += 1
|
||||
|
||||
self._set_cache(cache_key, stats)
|
||||
return stats
|
||||
|
||||
# Bulk Operations
|
||||
def bulk_close_issues(self, issue_numbers: List[int], comment: Optional[str] = None) -> List[Issue]:
|
||||
"""Close multiple issues."""
|
||||
results = []
|
||||
for number in issue_numbers:
|
||||
try:
|
||||
closed_issue = self.close_issue(number, comment)
|
||||
results.append(closed_issue)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to close issue #{number}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def bulk_add_label(self, issue_numbers: List[int], label_name: str) -> List[Issue]:
|
||||
"""Add label to multiple issues."""
|
||||
label = self.get_or_create_label(label_name)
|
||||
results = []
|
||||
|
||||
for number in issue_numbers:
|
||||
try:
|
||||
issue = self.get_issue_by_number(number)
|
||||
if issue:
|
||||
issue.add_label(label)
|
||||
updated_issue = self.update_issue(issue)
|
||||
results.append(updated_issue)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add label to issue #{number}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
# Cache Management
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data."""
|
||||
self._cache.clear()
|
||||
logger.info("Repository cache cleared")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics."""
|
||||
if not self.enable_caching:
|
||||
return {'enabled': False}
|
||||
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
expired_count = 0
|
||||
for cached_item in self._cache.values():
|
||||
if now - cached_item['timestamp'] > self._cache_timeout:
|
||||
expired_count += 1
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'total_entries': len(self._cache),
|
||||
'expired_entries': expired_count,
|
||||
'cache_timeout': self._cache_timeout
|
||||
}
|
||||
|
||||
# Context Manager Support
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if hasattr(self.backend, 'disconnect'):
|
||||
self.backend.disconnect()
|
||||
Reference in New Issue
Block a user