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:
2025-11-10 10:48:31 +01:00
parent 00b9834d2f
commit 34a8bc7d4c
19 changed files with 469 additions and 13 deletions

View 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())

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

View 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()