generated from coulomb/repo-seed
- 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>
407 lines
11 KiB
Python
407 lines
11 KiB
Python
"""
|
|
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()) |