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,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;