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:
11
issue_tracker/backends/__init__.py
Normal file
11
issue_tracker/backends/__init__.py
Normal 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
|
||||
"""
|
||||
17
issue_tracker/backends/gitea/__init__.py
Normal file
17
issue_tracker/backends/gitea/__init__.py
Normal 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']
|
||||
594
issue_tracker/backends/gitea/backend.py
Normal file
594
issue_tracker/backends/gitea/backend.py
Normal 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}")
|
||||
19
issue_tracker/backends/local/__init__.py
Normal file
19
issue_tracker/backends/local/__init__.py
Normal 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']
|
||||
618
issue_tracker/backends/local/backend.py
Normal file
618
issue_tracker/backends/local/backend.py
Normal 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)
|
||||
189
issue_tracker/backends/local/schema.sql
Normal file
189
issue_tracker/backends/local/schema.sql
Normal 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;
|
||||
Reference in New Issue
Block a user