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())
|
||||
Reference in New Issue
Block a user