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>
454 lines
15 KiB
Python
454 lines
15 KiB
Python
"""
|
|
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() |