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,11 @@
"""
Issue Tracking Backend Plugins
This package contains implementations for various issue tracking backends.
Each backend implements the IssueBackend interface to provide a consistent
API regardless of the underlying issue tracking system.
Available Backends:
- local: SQLite-based local backend for offline use
- gitea: Gitea API backend for GitHub-compatible systems
"""

View File

@@ -0,0 +1,17 @@
"""
Gitea Backend
A backend implementation for Gitea issue tracking systems.
This backend provides integration with Gitea API for remote issue management.
Features:
- Full Gitea API integration
- GitHub-compatible operations
- Remote synchronization
- Authentication support
- Rate limiting compliance
"""
from .backend import GiteaBackend
__all__ = ['GiteaBackend']

View File

@@ -0,0 +1,594 @@
"""
Gitea Backend Implementation
Provides integration with Gitea API for remote issue tracking.
This backend adapts the Gitea API to our unified issue model.
"""
import requests
import time
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
from urllib.parse import urljoin
from ...core.interfaces import RemoteBackend, BackendCapabilities, IssueFilter, SyncableBackend
from ...core.models import Issue, Label, User, Milestone, Comment, IssueState, Priority, IssueType
class GiteaAPIError(Exception):
"""Gitea API specific errors."""
pass
class GiteaRateLimitError(GiteaAPIError):
"""Rate limit exceeded."""
pass
class GiteaBackend(RemoteBackend, SyncableBackend):
"""Gitea API backend for remote issue tracking."""
def __init__(self):
self.base_url: Optional[str] = None
self.token: Optional[str] = None
self.owner: Optional[str] = None
self.repo: Optional[str] = None
self.session = requests.Session()
self._capabilities = BackendCapabilities(
supports_milestones=True,
supports_assignees=True,
supports_comments=True,
supports_labels=True,
supports_search=True,
supports_bulk_operations=False,
supports_webhooks=True,
supports_real_time=False,
max_labels_per_issue=None,
max_assignees_per_issue=10 # Gitea typical limit
)
@property
def backend_type(self) -> str:
return "gitea"
@property
def capabilities(self) -> BackendCapabilities:
return self._capabilities
def connect(self, config: Dict[str, Any]) -> None:
"""Connect to Gitea API."""
self.base_url = config['base_url'].rstrip('/')
self.token = config['token']
self.owner = config['owner']
self.repo = config['repo']
# Setup session with authentication
self.session.headers.update({
'Authorization': f'token {self.token}',
'Content-Type': 'application/json',
'Accept': 'application/json'
})
# Test connection
if not self.test_connection():
raise GiteaAPIError("Failed to connect to Gitea API")
def disconnect(self) -> None:
"""Disconnect from Gitea API."""
self.session.close()
self.base_url = None
self.token = None
self.owner = None
self.repo = None
def test_connection(self) -> bool:
"""Test Gitea API connection."""
try:
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}')
return response.status_code == 200
except Exception:
return False
def _api_request(self, method: str, endpoint: str, data: Optional[Dict] = None, params: Optional[Dict] = None) -> requests.Response:
"""Make API request with error handling and rate limiting."""
# Fix urljoin issue - ensure endpoint doesn't start with / when base ends with /
base = f"{self.base_url}/api/v1/"
endpoint = endpoint.lstrip('/')
url = urljoin(base, endpoint)
try:
response = self.session.request(method, url, json=data, params=params)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 60))
raise GiteaRateLimitError(f"Rate limit exceeded. Retry after {retry_after} seconds")
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
raise GiteaAPIError(f"API request failed: {e}")
def _gitea_issue_to_unified(self, gitea_issue: Dict[str, Any]) -> Issue:
"""Convert Gitea issue JSON to unified Issue model."""
# Convert labels
labels = []
for label_data in gitea_issue.get('labels', []):
labels.append(Label(
name=label_data['name'],
color=label_data['color'],
description=label_data.get('description', ''),
backend_id=str(label_data['id'])
))
# Convert assignees
assignees = []
if gitea_issue.get('assignees'):
for assignee_data in gitea_issue['assignees']:
assignees.append(User(
id=str(assignee_data['id']),
username=assignee_data['login'],
display_name=assignee_data.get('full_name', ''),
email=assignee_data.get('email', ''),
avatar_url=assignee_data.get('avatar_url', ''),
backend_id=str(assignee_data['id'])
))
# Convert milestone
milestone = None
if gitea_issue.get('milestone'):
milestone_data = gitea_issue['milestone']
milestone = Milestone(
id=str(milestone_data['id']),
title=milestone_data['title'],
description=milestone_data.get('description', ''),
state=milestone_data['state'],
due_date=datetime.fromisoformat(milestone_data['due_on'].replace('Z', '+00:00')) if milestone_data.get('due_on') else None,
created_at=datetime.fromisoformat(milestone_data['created_at'].replace('Z', '+00:00')) if milestone_data.get('created_at') else None,
updated_at=datetime.fromisoformat(milestone_data['updated_at'].replace('Z', '+00:00')) if milestone_data.get('updated_at') else None,
backend_id=str(milestone_data['id'])
)
# Determine state
if gitea_issue['state'] == 'closed':
state = IssueState.CLOSED
else:
# Check for status labels to determine more specific state
for label in labels:
if label.name == 'status:in_progress' or label.name == 'status:in-progress':
state = IssueState.IN_PROGRESS
break
elif label.name == 'status:blocked':
state = IssueState.BLOCKED
break
else:
state = IssueState.OPEN
return Issue(
id=str(gitea_issue['id']),
number=gitea_issue['number'],
title=gitea_issue['title'],
description=gitea_issue.get('body', ''),
state=state,
created_at=datetime.fromisoformat(gitea_issue['created_at'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(gitea_issue['updated_at'].replace('Z', '+00:00')),
closed_at=datetime.fromisoformat(gitea_issue['closed_at'].replace('Z', '+00:00')) if gitea_issue.get('closed_at') else None,
labels=labels,
assignees=assignees,
milestone=milestone,
backend_id=str(gitea_issue['id']),
backend_type='gitea'
)
def _unified_issue_to_gitea(self, issue: Issue) -> Dict[str, Any]:
"""Convert unified Issue to Gitea API format."""
data = {
'title': issue.title,
'body': issue.description,
'state': 'closed' if issue.state == IssueState.CLOSED else 'open'
}
if issue.assignees:
data['assignees'] = [assignee.username for assignee in issue.assignees]
if issue.milestone:
data['milestone'] = int(issue.milestone.backend_id) if issue.milestone.backend_id else None
# Convert labels
if issue.labels:
data['labels'] = [label.name for label in issue.labels]
return data
# Issue CRUD Operations
def create_issue(self, issue: Issue) -> Issue:
"""Create issue in Gitea."""
data = self._unified_issue_to_gitea(issue)
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/issues', data=data)
gitea_issue = response.json()
return self._gitea_issue_to_unified(gitea_issue)
def get_issue(self, issue_id: str) -> Optional[Issue]:
"""Get issue from Gitea by ID."""
try:
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/issues/{issue_id}')
gitea_issue = response.json()
return self._gitea_issue_to_unified(gitea_issue)
except GiteaAPIError:
return None
def get_issue_by_number(self, number: int) -> Optional[Issue]:
"""Get issue by number."""
return self.get_issue(str(number))
def update_issue(self, issue: Issue) -> Issue:
"""Update issue in Gitea."""
data = self._unified_issue_to_gitea(issue)
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/issues/{issue.number}', data=data)
gitea_issue = response.json()
return self._gitea_issue_to_unified(gitea_issue)
def delete_issue(self, issue_id: str) -> bool:
"""Delete issue - not supported by Gitea API."""
# Gitea doesn't support deleting issues via API
# We could close it instead
try:
issue = self.get_issue(issue_id)
if issue:
issue.close()
self.update_issue(issue)
return True
except Exception:
pass
return False
def list_issues(self, filter_criteria: Optional[IssueFilter] = None) -> List[Issue]:
"""List issues from Gitea."""
params = {
'state': 'all', # Get both open and closed
'sort': 'updated',
'order': 'desc'
}
if filter_criteria:
if filter_criteria.state:
if filter_criteria.state == 'open':
params['state'] = 'open'
elif filter_criteria.state == 'closed':
params['state'] = 'closed'
if filter_criteria.assignee:
params['assignee'] = filter_criteria.assignee
if filter_criteria.milestone:
params['milestone'] = filter_criteria.milestone
if filter_criteria.labels:
params['labels'] = ','.join(filter_criteria.labels)
if filter_criteria.created_after:
params['since'] = filter_criteria.created_after.isoformat()
if filter_criteria.limit:
params['limit'] = filter_criteria.limit
if filter_criteria.offset:
params['page'] = (filter_criteria.offset // (filter_criteria.limit or 30)) + 1
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/issues', params=params)
gitea_issues = response.json()
issues = [self._gitea_issue_to_unified(gitea_issue) for gitea_issue in gitea_issues]
# Apply additional filtering that Gitea API doesn't support
if filter_criteria:
if filter_criteria.search:
search_term = filter_criteria.search.lower()
issues = [
issue for issue in issues
if search_term in issue.title.lower() or search_term in issue.description.lower()
]
return issues
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
"""Search issues - limited Gitea API support."""
# Gitea has limited search API, fallback to list with search filter
filter_criteria = IssueFilter(search=query, limit=limit)
return self.list_issues(filter_criteria)
# Label Operations
def create_label(self, label: Label) -> Label:
"""Create label in Gitea."""
data = {
'name': label.name,
'color': label.color or '#000000',
'description': label.description or ''
}
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/labels', data=data)
gitea_label = response.json()
return Label(
name=gitea_label['name'],
color=gitea_label['color'],
description=gitea_label.get('description', ''),
backend_id=str(gitea_label['id'])
)
def get_labels(self) -> List[Label]:
"""Get all labels from Gitea."""
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/labels')
gitea_labels = response.json()
return [Label(
name=label['name'],
color=label['color'],
description=label.get('description', ''),
backend_id=str(label['id'])
) for label in gitea_labels]
def update_label(self, label: Label) -> Label:
"""Update label in Gitea."""
data = {
'name': label.name,
'color': label.color or '#000000',
'description': label.description or ''
}
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/labels/{label.name}', data=data)
gitea_label = response.json()
return Label(
name=gitea_label['name'],
color=gitea_label['color'],
description=gitea_label.get('description', ''),
backend_id=str(gitea_label['id'])
)
def delete_label(self, label_name: str) -> bool:
"""Delete label from Gitea."""
try:
self._api_request('DELETE', f'/repos/{self.owner}/{self.repo}/labels/{label_name}')
return True
except GiteaAPIError:
return False
# User Operations
def get_users(self) -> List[User]:
"""Get repository collaborators."""
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/collaborators')
gitea_users = response.json()
return [User(
id=str(user['id']),
username=user['login'],
display_name=user.get('full_name', ''),
email=user.get('email', ''),
avatar_url=user.get('avatar_url', ''),
backend_id=str(user['id'])
) for user in gitea_users]
def get_user(self, user_id: str) -> Optional[User]:
"""Get specific user."""
try:
response = self._api_request('GET', f'/users/{user_id}')
user = response.json()
return User(
id=str(user['id']),
username=user['login'],
display_name=user.get('full_name', ''),
email=user.get('email', ''),
avatar_url=user.get('avatar_url', ''),
backend_id=str(user['id'])
)
except GiteaAPIError:
return None
def search_users(self, query: str) -> List[User]:
"""Search users in Gitea."""
params = {'q': query, 'limit': 50}
response = self._api_request('GET', '/users/search', params=params)
search_result = response.json()
return [User(
id=str(user['id']),
username=user['login'],
display_name=user.get('full_name', ''),
email=user.get('email', ''),
avatar_url=user.get('avatar_url', ''),
backend_id=str(user['id'])
) for user in search_result.get('data', [])]
# Milestone Operations
def create_milestone(self, milestone: Milestone) -> Milestone:
"""Create milestone in Gitea."""
data = {
'title': milestone.title,
'description': milestone.description or '',
'due_on': milestone.due_date.isoformat() if milestone.due_date else None
}
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/milestones', data=data)
gitea_milestone = response.json()
return Milestone(
id=str(gitea_milestone['id']),
title=gitea_milestone['title'],
description=gitea_milestone.get('description', ''),
state=gitea_milestone['state'],
due_date=datetime.fromisoformat(gitea_milestone['due_on'].replace('Z', '+00:00')) if gitea_milestone.get('due_on') else None,
created_at=datetime.fromisoformat(gitea_milestone['created_at'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(gitea_milestone['updated_at'].replace('Z', '+00:00')),
backend_id=str(gitea_milestone['id'])
)
def get_milestones(self) -> List[Milestone]:
"""Get all milestones."""
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/milestones')
gitea_milestones = response.json()
return [Milestone(
id=str(m['id']),
title=m['title'],
description=m.get('description', ''),
state=m['state'],
due_date=datetime.fromisoformat(m['due_on'].replace('Z', '+00:00')) if m.get('due_on') else None,
created_at=datetime.fromisoformat(m['created_at'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(m['updated_at'].replace('Z', '+00:00')),
backend_id=str(m['id'])
) for m in gitea_milestones]
def update_milestone(self, milestone: Milestone) -> Milestone:
"""Update milestone."""
data = {
'title': milestone.title,
'description': milestone.description or '',
'state': milestone.state,
'due_on': milestone.due_date.isoformat() if milestone.due_date else None
}
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/milestones/{milestone.backend_id}', data=data)
gitea_milestone = response.json()
return Milestone(
id=str(gitea_milestone['id']),
title=gitea_milestone['title'],
description=gitea_milestone.get('description', ''),
state=gitea_milestone['state'],
due_date=datetime.fromisoformat(gitea_milestone['due_on'].replace('Z', '+00:00')) if gitea_milestone.get('due_on') else None,
created_at=datetime.fromisoformat(gitea_milestone['created_at'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(gitea_milestone['updated_at'].replace('Z', '+00:00')),
backend_id=str(gitea_milestone['id'])
)
def delete_milestone(self, milestone_id: str) -> bool:
"""Delete milestone."""
try:
self._api_request('DELETE', f'/repos/{self.owner}/{self.repo}/milestones/{milestone_id}')
return True
except GiteaAPIError:
return False
# Comment Operations
def add_comment(self, issue_id: str, comment: Comment) -> Comment:
"""Add comment to issue."""
data = {'body': comment.body}
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/issues/{issue_id}/comments', data=data)
gitea_comment = response.json()
# Convert author
author_data = gitea_comment['user']
author = User(
id=str(author_data['id']),
username=author_data['login'],
display_name=author_data.get('full_name', ''),
email=author_data.get('email', ''),
avatar_url=author_data.get('avatar_url', ''),
backend_id=str(author_data['id'])
)
return Comment(
id=str(gitea_comment['id']),
body=gitea_comment['body'],
author=author,
created_at=datetime.fromisoformat(gitea_comment['created_at'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(gitea_comment['updated_at'].replace('Z', '+00:00')),
backend_id=str(gitea_comment['id'])
)
def get_comments(self, issue_id: str) -> List[Comment]:
"""Get comments for issue."""
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/issues/{issue_id}/comments')
gitea_comments = response.json()
comments = []
for gc in gitea_comments:
author_data = gc['user']
author = User(
id=str(author_data['id']),
username=author_data['login'],
display_name=author_data.get('full_name', ''),
email=author_data.get('email', ''),
avatar_url=author_data.get('avatar_url', ''),
backend_id=str(author_data['id'])
)
comment = Comment(
id=str(gc['id']),
body=gc['body'],
author=author,
created_at=datetime.fromisoformat(gc['created_at'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(gc['updated_at'].replace('Z', '+00:00')),
backend_id=str(gc['id'])
)
comments.append(comment)
return comments
def update_comment(self, comment: Comment) -> Comment:
"""Update comment."""
data = {'body': comment.body}
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/issues/comments/{comment.backend_id}', data=data)
gitea_comment = response.json()
# Update comment object
comment.updated_at = datetime.fromisoformat(gitea_comment['updated_at'].replace('Z', '+00:00'))
return comment
def delete_comment(self, comment_id: str) -> bool:
"""Delete comment."""
try:
self._api_request('DELETE', f'/repos/{self.owner}/{self.repo}/issues/comments/{comment_id}')
return True
except GiteaAPIError:
return False
# Sync Support
def get_last_sync_timestamp(self) -> Optional[datetime]:
"""Get last sync timestamp - stored in metadata."""
# Could be stored in repository description or other metadata
return None
def get_issues_modified_since(self, timestamp: datetime) -> List[Issue]:
"""Get issues modified since timestamp."""
filter_criteria = IssueFilter(updated_after=timestamp)
return self.list_issues(filter_criteria)
# SyncableBackend Implementation
def prepare_for_sync(self) -> None:
"""Prepare for sync operation."""
# Could implement rate limiting preparation
pass
def finalize_sync(self, success: bool) -> None:
"""Finalize sync operation."""
# Could log sync status
pass
def get_sync_conflicts(self) -> List[Dict[str, Any]]:
"""Get sync conflicts."""
# Would compare timestamps and detect conflicts
return []
def resolve_sync_conflict(self, issue_id: str, resolution: str) -> Issue:
"""Resolve sync conflict."""
issue = self.get_issue(issue_id)
if not issue:
raise GiteaAPIError(f"Issue {issue_id} not found")
if resolution == 'remote':
# Keep remote version (current issue)
return issue
elif resolution == 'local':
# This would require the local version to be provided
raise NotImplementedError("Local resolution requires local issue data")
else:
raise ValueError(f"Unknown resolution: {resolution}")

View File

@@ -0,0 +1,19 @@
"""
Local SQLite Backend
A local, file-based issue tracking backend using SQLite for storage.
This backend provides complete offline functionality and serves as the
reference implementation for the backend interface.
Features:
- Full CRUD operations
- SQLite database storage
- No external dependencies
- Offline operation
- Fast local search
- Backup and export capabilities
"""
from .backend import LocalSQLiteBackend
__all__ = ['LocalSQLiteBackend']

View File

@@ -0,0 +1,618 @@
"""
Local SQLite Backend Implementation
Provides a complete local issue tracking backend using SQLite for storage.
This implementation serves as the reference for the backend interface and
provides full offline functionality.
"""
import sqlite3
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Dict, Any
from ...core.interfaces import LocalBackend, BackendCapabilities, IssueFilter, SyncableBackend
from ...core.models import Issue, Label, User, Milestone, Comment, IssueState, Priority, IssueType
class LocalSQLiteBackend(LocalBackend, SyncableBackend):
"""SQLite-based local backend for issue tracking."""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or "issues.db"
self.connection: Optional[sqlite3.Connection] = None
self._capabilities = BackendCapabilities(
supports_milestones=True,
supports_assignees=True,
supports_comments=True,
supports_labels=True,
supports_search=True,
supports_bulk_operations=True,
supports_webhooks=False,
supports_real_time=False,
max_labels_per_issue=None,
max_assignees_per_issue=None
)
@property
def backend_type(self) -> str:
return "local"
@property
def capabilities(self) -> BackendCapabilities:
return self._capabilities
def connect(self, config: Dict[str, Any]) -> None:
"""Connect to SQLite database."""
db_path = config.get('db_path', self.db_path)
self.db_path = db_path
# Ensure directory exists
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(db_path)
self.connection.row_factory = sqlite3.Row # Enable dict-like access
self.connection.execute("PRAGMA foreign_keys = ON")
# Initialize schema
self._initialize_schema()
def disconnect(self) -> None:
"""Disconnect from database."""
if self.connection:
self.connection.close()
self.connection = None
def test_connection(self) -> bool:
"""Test database connection."""
if not self.connection:
return False
try:
self.connection.execute("SELECT 1")
return True
except sqlite3.Error:
return False
def _initialize_schema(self) -> None:
"""Initialize database schema."""
schema_path = Path(__file__).parent / "schema.sql"
with open(schema_path, 'r') as f:
schema_sql = f.read()
# Execute schema in parts (SQLite doesn't like multiple statements)
for statement in schema_sql.split(';'):
statement = statement.strip()
if statement:
self.connection.execute(statement)
self.connection.commit()
def _get_next_issue_number(self) -> int:
"""Get the next available issue number."""
cursor = self.connection.execute("SELECT MAX(number) FROM issues")
result = cursor.fetchone()
return (result[0] or 0) + 1
def _issue_from_row(self, row: sqlite3.Row) -> Issue:
"""Convert database row to Issue object."""
# Get labels
cursor = self.connection.execute("""
SELECT l.id, l.name, l.color, l.description, l.backend_id
FROM labels l
JOIN issue_labels il ON l.id = il.label_id
WHERE il.issue_id = ?
""", (row['id'],))
label_rows = cursor.fetchall()
labels = [Label(
name=lr['name'],
color=lr['color'],
description=lr['description'],
backend_id=lr['backend_id']
) for lr in label_rows]
# Get assignees
cursor = self.connection.execute("""
SELECT u.id, u.username, u.display_name, u.email, u.avatar_url, u.backend_id
FROM users u
JOIN issue_assignees ia ON u.id = ia.user_id
WHERE ia.issue_id = ?
""", (row['id'],))
user_rows = cursor.fetchall()
assignees = [User(
id=ur['id'],
username=ur['username'],
display_name=ur['display_name'],
email=ur['email'],
avatar_url=ur['avatar_url'],
backend_id=ur['backend_id']
) for ur in user_rows]
# Get milestone
milestone = None
if row['milestone_id']:
cursor = self.connection.execute("""
SELECT id, title, description, state, due_date, created_at, updated_at, backend_id
FROM milestones WHERE id = ?
""", (row['milestone_id'],))
m_row = cursor.fetchone()
if m_row:
milestone = Milestone(
id=m_row['id'],
title=m_row['title'],
description=m_row['description'],
state=m_row['state'],
due_date=datetime.fromisoformat(m_row['due_date']) if m_row['due_date'] else None,
created_at=datetime.fromisoformat(m_row['created_at']) if m_row['created_at'] else None,
updated_at=datetime.fromisoformat(m_row['updated_at']) if m_row['updated_at'] else None,
backend_id=m_row['backend_id']
)
# Parse sync metadata
sync_metadata = {}
if row['sync_metadata']:
try:
sync_metadata = json.loads(row['sync_metadata'])
except json.JSONDecodeError:
pass
return Issue(
id=row['id'],
number=row['number'],
title=row['title'],
description=row['description'],
state=IssueState.from_string(row['state']),
created_at=datetime.fromisoformat(row['created_at']),
updated_at=datetime.fromisoformat(row['updated_at']),
closed_at=datetime.fromisoformat(row['closed_at']) if row['closed_at'] else None,
labels=labels,
assignees=assignees,
milestone=milestone,
backend_id=row['backend_id'],
backend_type=row['backend_type'],
sync_metadata=sync_metadata
)
# Issue CRUD Operations
def create_issue(self, issue: Issue) -> Issue:
"""Create a new issue."""
if not issue.id:
issue.id = str(uuid.uuid4())
if not issue.number:
issue.number = self._get_next_issue_number()
# Insert issue
self.connection.execute("""
INSERT INTO issues (id, number, title, description, state, created_at, updated_at,
closed_at, milestone_id, backend_id, backend_type, sync_metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
issue.id,
issue.number,
issue.title,
issue.description,
issue.state.value,
issue.created_at.isoformat(),
issue.updated_at.isoformat(),
issue.closed_at.isoformat() if issue.closed_at else None,
issue.milestone.id if issue.milestone else None,
issue.backend_id,
issue.backend_type or 'local',
json.dumps(issue.sync_metadata) if issue.sync_metadata else None
))
# Add labels
for label in issue.labels:
self._ensure_label_exists(label)
self.connection.execute("""
INSERT OR IGNORE INTO issue_labels (issue_id, label_id)
VALUES (?, ?)
""", (issue.id, label.name)) # Using name as ID for simplicity
# Add assignees
for user in issue.assignees:
self._ensure_user_exists(user)
self.connection.execute("""
INSERT OR IGNORE INTO issue_assignees (issue_id, user_id)
VALUES (?, ?)
""", (issue.id, user.id))
self.connection.commit()
return issue
def get_issue(self, issue_id: str) -> Optional[Issue]:
"""Get issue by ID."""
cursor = self.connection.execute("""
SELECT * FROM issues WHERE id = ? OR backend_id = ?
""", (issue_id, issue_id))
row = cursor.fetchone()
return self._issue_from_row(row) if row else None
def get_issue_by_number(self, number: int) -> Optional[Issue]:
"""Get issue by number."""
cursor = self.connection.execute("""
SELECT * FROM issues WHERE number = ?
""", (number,))
row = cursor.fetchone()
return self._issue_from_row(row) if row else None
def update_issue(self, issue: Issue) -> Issue:
"""Update existing issue."""
# Update main issue record
self.connection.execute("""
UPDATE issues SET
title = ?, description = ?, state = ?, updated_at = ?,
closed_at = ?, milestone_id = ?, sync_metadata = ?
WHERE id = ?
""", (
issue.title,
issue.description,
issue.state.value,
issue.updated_at.isoformat(),
issue.closed_at.isoformat() if issue.closed_at else None,
issue.milestone.id if issue.milestone else None,
json.dumps(issue.sync_metadata) if issue.sync_metadata else None,
issue.id
))
# Update labels (remove all and re-add)
self.connection.execute("DELETE FROM issue_labels WHERE issue_id = ?", (issue.id,))
for label in issue.labels:
self._ensure_label_exists(label)
self.connection.execute("""
INSERT INTO issue_labels (issue_id, label_id) VALUES (?, ?)
""", (issue.id, label.name))
# Update assignees (remove all and re-add)
self.connection.execute("DELETE FROM issue_assignees WHERE issue_id = ?", (issue.id,))
for user in issue.assignees:
self._ensure_user_exists(user)
self.connection.execute("""
INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?)
""", (issue.id, user.id))
self.connection.commit()
return issue
def delete_issue(self, issue_id: str) -> bool:
"""Delete issue."""
cursor = self.connection.execute("DELETE FROM issues WHERE id = ?", (issue_id,))
self.connection.commit()
return cursor.rowcount > 0
def list_issues(self, filter_criteria: Optional[IssueFilter] = None) -> List[Issue]:
"""List issues with optional filtering."""
query = "SELECT * FROM issues WHERE 1=1"
params = []
if filter_criteria:
if filter_criteria.state:
query += " AND state = ?"
params.append(filter_criteria.state)
if filter_criteria.search:
query += " AND (title LIKE ? OR description LIKE ?)"
search_term = f"%{filter_criteria.search}%"
params.extend([search_term, search_term])
if filter_criteria.created_after:
query += " AND created_at >= ?"
params.append(filter_criteria.created_after.isoformat())
if filter_criteria.created_before:
query += " AND created_at <= ?"
params.append(filter_criteria.created_before.isoformat())
if filter_criteria.updated_after:
query += " AND updated_at >= ?"
params.append(filter_criteria.updated_after.isoformat())
if filter_criteria.updated_before:
query += " AND updated_at <= ?"
params.append(filter_criteria.updated_before.isoformat())
query += " ORDER BY updated_at DESC"
if filter_criteria and filter_criteria.limit:
query += " LIMIT ?"
params.append(filter_criteria.limit)
if filter_criteria.offset:
query += " OFFSET ?"
params.append(filter_criteria.offset)
cursor = self.connection.execute(query, params)
rows = cursor.fetchall()
return [self._issue_from_row(row) for row in rows]
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
"""Search issues using FTS if available, otherwise fallback to LIKE."""
try:
# Try FTS search first
fts_query = """
SELECT i.* FROM issues i
JOIN issue_search s ON i.id = s.issue_id
WHERE issue_search MATCH ?
ORDER BY rank
"""
params = [query]
if limit:
fts_query += " LIMIT ?"
params.append(limit)
cursor = self.connection.execute(fts_query, params)
rows = cursor.fetchall()
return [self._issue_from_row(row) for row in rows]
except sqlite3.OperationalError:
# Fallback to LIKE search
filter_criteria = IssueFilter(search=query, limit=limit)
return self.list_issues(filter_criteria)
# Helper methods
def _ensure_label_exists(self, label: Label) -> None:
"""Ensure label exists in database."""
self.connection.execute("""
INSERT OR IGNORE INTO labels (id, name, color, description, backend_id)
VALUES (?, ?, ?, ?, ?)
""", (label.name, label.name, label.color, label.description, label.backend_id))
def _ensure_user_exists(self, user: User) -> None:
"""Ensure user exists in database."""
self.connection.execute("""
INSERT OR IGNORE INTO users (id, username, display_name, email, avatar_url, backend_id)
VALUES (?, ?, ?, ?, ?, ?)
""", (user.id, user.username, user.display_name, user.email, user.avatar_url, user.backend_id))
# Label Operations
def create_label(self, label: Label) -> Label:
"""Create a new label."""
label_id = label.name # Use name as ID
self.connection.execute("""
INSERT INTO labels (id, name, color, description, backend_id)
VALUES (?, ?, ?, ?, ?)
""", (label_id, label.name, label.color, label.description, label.backend_id))
self.connection.commit()
return label
def get_labels(self) -> List[Label]:
"""Get all labels."""
cursor = self.connection.execute("SELECT * FROM labels ORDER BY name")
rows = cursor.fetchall()
return [Label(
name=row['name'],
color=row['color'],
description=row['description'],
backend_id=row['backend_id']
) for row in rows]
def update_label(self, label: Label) -> Label:
"""Update label."""
self.connection.execute("""
UPDATE labels SET color = ?, description = ? WHERE name = ?
""", (label.color, label.description, label.name))
self.connection.commit()
return label
def delete_label(self, label_name: str) -> bool:
"""Delete label."""
cursor = self.connection.execute("DELETE FROM labels WHERE name = ?", (label_name,))
self.connection.commit()
return cursor.rowcount > 0
# User Operations
def get_users(self) -> List[User]:
"""Get all users."""
cursor = self.connection.execute("SELECT * FROM users ORDER BY username")
rows = cursor.fetchall()
return [User(
id=row['id'],
username=row['username'],
display_name=row['display_name'],
email=row['email'],
avatar_url=row['avatar_url'],
backend_id=row['backend_id']
) for row in rows]
def get_user(self, user_id: str) -> Optional[User]:
"""Get user by ID."""
cursor = self.connection.execute("SELECT * FROM users WHERE id = ?", (user_id,))
row = cursor.fetchone()
if row:
return User(
id=row['id'],
username=row['username'],
display_name=row['display_name'],
email=row['email'],
avatar_url=row['avatar_url'],
backend_id=row['backend_id']
)
return None
def search_users(self, query: str) -> List[User]:
"""Search users."""
cursor = self.connection.execute("""
SELECT * FROM users
WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?
ORDER BY username
""", (f"%{query}%", f"%{query}%", f"%{query}%"))
rows = cursor.fetchall()
return [User(
id=row['id'],
username=row['username'],
display_name=row['display_name'],
email=row['email'],
avatar_url=row['avatar_url'],
backend_id=row['backend_id']
) for row in rows]
# Milestone Operations
def create_milestone(self, milestone: Milestone) -> Milestone:
"""Create milestone."""
if not milestone.id:
milestone.id = str(uuid.uuid4())
self.connection.execute("""
INSERT INTO milestones (id, title, description, state, due_date, created_at, updated_at, backend_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
milestone.id,
milestone.title,
milestone.description,
milestone.state,
milestone.due_date.isoformat() if milestone.due_date else None,
milestone.created_at.isoformat() if milestone.created_at else datetime.now(timezone.utc).isoformat(),
milestone.updated_at.isoformat() if milestone.updated_at else datetime.now(timezone.utc).isoformat(),
milestone.backend_id
))
self.connection.commit()
return milestone
def get_milestones(self) -> List[Milestone]:
"""Get all milestones."""
cursor = self.connection.execute("SELECT * FROM milestones ORDER BY title")
rows = cursor.fetchall()
return [Milestone(
id=row['id'],
title=row['title'],
description=row['description'],
state=row['state'],
due_date=datetime.fromisoformat(row['due_date']) if row['due_date'] else None,
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
backend_id=row['backend_id']
) for row in rows]
def update_milestone(self, milestone: Milestone) -> Milestone:
"""Update milestone."""
self.connection.execute("""
UPDATE milestones SET title = ?, description = ?, state = ?, due_date = ?, updated_at = ?
WHERE id = ?
""", (
milestone.title,
milestone.description,
milestone.state,
milestone.due_date.isoformat() if milestone.due_date else None,
datetime.now(timezone.utc).isoformat(),
milestone.id
))
self.connection.commit()
return milestone
def delete_milestone(self, milestone_id: str) -> bool:
"""Delete milestone."""
cursor = self.connection.execute("DELETE FROM milestones WHERE id = ?", (milestone_id,))
self.connection.commit()
return cursor.rowcount > 0
# Comment Operations
def add_comment(self, issue_id: str, comment: Comment) -> Comment:
"""Add comment to issue."""
if not comment.id:
comment.id = str(uuid.uuid4())
self._ensure_user_exists(comment.author)
self.connection.execute("""
INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at, backend_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
comment.id,
issue_id,
comment.author.id,
comment.body,
comment.created_at.isoformat(),
comment.updated_at.isoformat() if comment.updated_at else None,
comment.backend_id
))
self.connection.commit()
return comment
def get_comments(self, issue_id: str) -> List[Comment]:
"""Get comments for issue."""
cursor = self.connection.execute("""
SELECT c.*, u.id as user_id, u.username, u.display_name, u.email, u.avatar_url, u.backend_id as user_backend_id
FROM comments c
JOIN users u ON c.author_id = u.id
WHERE c.issue_id = ?
ORDER BY c.created_at
""", (issue_id,))
rows = cursor.fetchall()
comments = []
for row in rows:
author = User(
id=row['user_id'],
username=row['username'],
display_name=row['display_name'],
email=row['email'],
avatar_url=row['avatar_url'],
backend_id=row['user_backend_id']
)
comment = Comment(
id=row['id'],
body=row['body'],
author=author,
created_at=datetime.fromisoformat(row['created_at']),
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
backend_id=row['backend_id']
)
comments.append(comment)
return comments
def update_comment(self, comment: Comment) -> Comment:
"""Update comment."""
self.connection.execute("""
UPDATE comments SET body = ?, updated_at = ? WHERE id = ?
""", (comment.body, datetime.now(timezone.utc).isoformat(), comment.id))
self.connection.commit()
return comment
def delete_comment(self, comment_id: str) -> bool:
"""Delete comment."""
cursor = self.connection.execute("DELETE FROM comments WHERE id = ?", (comment_id,))
self.connection.commit()
return cursor.rowcount > 0
# Sync Support
def get_last_sync_timestamp(self) -> Optional[datetime]:
"""Get last sync timestamp."""
cursor = self.connection.execute("""
SELECT sync_timestamp FROM sync_history
WHERE success = 1
ORDER BY sync_timestamp DESC
LIMIT 1
""")
row = cursor.fetchone()
return datetime.fromisoformat(row[0]) if row else None
def get_issues_modified_since(self, timestamp: datetime) -> List[Issue]:
"""Get issues modified since timestamp."""
filter_criteria = IssueFilter(updated_after=timestamp)
return self.list_issues(filter_criteria)
# SyncableBackend Implementation
def prepare_for_sync(self) -> None:
"""Prepare for sync operation."""
# Could create backup or start transaction
pass
def finalize_sync(self, success: bool) -> None:
"""Finalize sync operation."""
# Log sync operation
self.connection.execute("""
INSERT INTO sync_history (backend_type, success, sync_timestamp)
VALUES (?, ?, ?)
""", ('sync', success, datetime.now(timezone.utc).isoformat()))
self.connection.commit()
def get_sync_conflicts(self) -> List[Dict[str, Any]]:
"""Get sync conflicts."""
# For local backend, no conflicts since it's the source of truth
return []
def resolve_sync_conflict(self, issue_id: str, resolution: str) -> Issue:
"""Resolve sync conflict."""
# Local backend doesn't have conflicts
return self.get_issue(issue_id)

View File

@@ -0,0 +1,189 @@
-- Local Issue Tracking Database Schema
-- SQLite schema for local issue storage with full referential integrity
-- Enable foreign key constraints
PRAGMA foreign_keys = ON;
-- Issues table - core issue data
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
number INTEGER UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL CHECK (state IN ('open', 'closed', 'in_progress', 'blocked')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
milestone_id TEXT,
backend_id TEXT,
backend_type TEXT DEFAULT 'local',
sync_metadata TEXT, -- JSON for sync data
FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE SET NULL
);
-- Create index for issue number lookups
CREATE INDEX IF NOT EXISTS idx_issues_number ON issues(number);
CREATE INDEX IF NOT EXISTS idx_issues_state ON issues(state);
CREATE INDEX IF NOT EXISTS idx_issues_updated_at ON issues(updated_at);
CREATE INDEX IF NOT EXISTS idx_issues_backend_id ON issues(backend_id);
-- Labels table
CREATE TABLE IF NOT EXISTS labels (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT,
description TEXT,
backend_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create index for label name lookups
CREATE INDEX IF NOT EXISTS idx_labels_name ON labels(name);
-- Issue-Label many-to-many relationship
CREATE TABLE IF NOT EXISTS issue_labels (
issue_id TEXT NOT NULL,
label_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (issue_id, label_id),
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
);
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
email TEXT,
avatar_url TEXT,
backend_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create index for username lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- Issue-User assignment many-to-many relationship
CREATE TABLE IF NOT EXISTS issue_assignees (
issue_id TEXT NOT NULL,
user_id TEXT NOT NULL,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (issue_id, user_id),
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Milestones table
CREATE TABLE IF NOT EXISTS milestones (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
due_date TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
backend_id TEXT
);
-- Create index for milestone title lookups
CREATE INDEX IF NOT EXISTS idx_milestones_title ON milestones(title);
-- Comments table
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
issue_id TEXT NOT NULL,
author_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
backend_id TEXT,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Create index for comment lookups
CREATE INDEX IF NOT EXISTS idx_comments_issue_id ON comments(issue_id);
CREATE INDEX IF NOT EXISTS idx_comments_created_at ON comments(created_at);
-- Sync tracking table
CREATE TABLE IF NOT EXISTS sync_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
backend_type TEXT NOT NULL,
sync_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN NOT NULL,
issues_synced INTEGER DEFAULT 0,
errors_count INTEGER DEFAULT 0,
details TEXT -- JSON for sync details
);
-- Configuration table for backend settings
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Triggers to automatically update updated_at timestamps
CREATE TRIGGER IF NOT EXISTS update_issues_timestamp
AFTER UPDATE ON issues
BEGIN
UPDATE issues SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS update_milestones_timestamp
AFTER UPDATE ON milestones
BEGIN
UPDATE milestones SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Views for common queries
CREATE VIEW IF NOT EXISTS issue_summary AS
SELECT
i.id,
i.number,
i.title,
i.state,
i.created_at,
i.updated_at,
i.closed_at,
m.title as milestone_title,
COUNT(c.id) as comment_count,
GROUP_CONCAT(l.name) as labels,
GROUP_CONCAT(u.username) as assignees
FROM issues i
LEFT JOIN milestones m ON i.milestone_id = m.id
LEFT JOIN comments c ON i.id = c.issue_id
LEFT JOIN issue_labels il ON i.id = il.issue_id
LEFT JOIN labels l ON il.label_id = l.id
LEFT JOIN issue_assignees ia ON i.id = ia.issue_id
LEFT JOIN users u ON ia.user_id = u.id
GROUP BY i.id, i.number, i.title, i.state, i.created_at, i.updated_at, i.closed_at, m.title;
-- Full-text search setup (if SQLite supports FTS)
CREATE VIRTUAL TABLE IF NOT EXISTS issue_search USING fts5(
issue_id,
title,
description,
labels,
content='issues'
);
-- Trigger to keep FTS index updated
CREATE TRIGGER IF NOT EXISTS issue_search_insert AFTER INSERT ON issues
BEGIN
INSERT INTO issue_search(issue_id, title, description)
VALUES (NEW.id, NEW.title, NEW.description);
END;
CREATE TRIGGER IF NOT EXISTS issue_search_update AFTER UPDATE ON issues
BEGIN
UPDATE issue_search
SET title = NEW.title, description = NEW.description
WHERE issue_id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS issue_search_delete AFTER DELETE ON issues
BEGIN
DELETE FROM issue_search WHERE issue_id = OLD.id;
END;