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

336
issue_tracker/cli/utils.py Normal file
View File

@@ -0,0 +1,336 @@
"""
CLI Utility Functions
Helper functions for the CLI commands including formatting, configuration,
backend management, and user interaction.
"""
import click
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, List
import tempfile
from ..core.interfaces import IssueBackend, BackendFactory
from ..core.models import Issue, Comment
from ..backends.local import LocalSQLiteBackend
from ..backends.gitea import GiteaBackend
# Register available backends
BackendFactory.register_backend('local', LocalSQLiteBackend)
BackendFactory.register_backend('gitea', GiteaBackend)
def get_config_dir() -> Path:
"""Get configuration directory."""
config_dir = Path.home() / '.config' / 'issue-tracker'
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
def get_backend_config_path() -> Path:
"""Get backend configuration file path."""
return get_config_dir() / 'backends.json'
def load_backend_configs() -> dict:
"""Load backend configurations."""
config_path = get_backend_config_path()
if not config_path.exists():
return {}
import json
try:
with open(config_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def save_backend_configs(configs: dict) -> None:
"""Save backend configurations."""
config_path = get_backend_config_path()
import json
with open(config_path, 'w') as f:
json.dump(configs, f, indent=2)
def get_default_backend() -> str:
"""Get the default backend name."""
configs = load_backend_configs()
return configs.get('default', 'local')
def get_backend(ctx) -> IssueBackend:
"""Get backend instance from context."""
backend_name = ctx.obj.get('backend') or get_default_backend()
configs = load_backend_configs()
if backend_name not in configs:
if backend_name == 'local':
# Auto-configure local backend
local_config = {
'type': 'local',
'db_path': str(get_config_dir() / 'issues.db')
}
configs['local'] = local_config
save_backend_configs(configs)
else:
raise click.ClickException(f"Backend '{backend_name}' not configured. Use 'backend add' to configure it.")
backend_config = configs[backend_name]
backend_type = backend_config['type']
try:
backend = BackendFactory.create_backend(backend_type)
backend.connect(backend_config)
return backend
except Exception as e:
raise click.ClickException(f"Failed to connect to backend '{backend_name}': {e}")
def format_issue_list(issues: List[Issue]) -> str:
"""Format list of issues as a table."""
if not issues:
return "No issues found."
# Calculate column widths
max_title_width = min(50, max(len(issue.title) for issue in issues))
max_assignee_width = 15
# Header
lines = []
header = f"{'#':<6} {'State':<12} {'Title':<{max_title_width}} {'Assignee':<{max_assignee_width}} Labels"
lines.append(header)
lines.append("-" * len(header))
# Issues
for issue in issues:
title = issue.title[:max_title_width]
if len(issue.title) > max_title_width:
title = title[:-3] + "..."
assignee = issue.primary_assignee.username if issue.primary_assignee else "unassigned"
assignee = assignee[:max_assignee_width]
labels = ", ".join(label.name for label in issue.labels[:3])
if len(issue.labels) > 3:
labels += f" (+{len(issue.labels) - 3})"
line = f"{issue.number:<6} {issue.state.value:<12} {title:<{max_title_width}} {assignee:<{max_assignee_width}} {labels}"
lines.append(line)
return "\n".join(lines)
def format_issue(issue: Issue, show_comments: bool = False, backend: Optional[IssueBackend] = None) -> str:
"""Format a single issue with details."""
lines = []
# Header
lines.append(f"#{issue.number}: {issue.title}")
lines.append("=" * (len(f"#{issue.number}: {issue.title}")))
lines.append("")
# Basic info
lines.append(f"State: {issue.state.value}")
lines.append(f"Created: {format_datetime(issue.created_at)}")
lines.append(f"Updated: {format_datetime(issue.updated_at)}")
if issue.closed_at:
lines.append(f"Closed: {format_datetime(issue.closed_at)}")
# Assignees
if issue.assignees:
assignees_str = ", ".join(assignee.username for assignee in issue.assignees)
lines.append(f"Assignees: {assignees_str}")
else:
lines.append("Assignees: none")
# Milestone
if issue.milestone:
lines.append(f"Milestone: {issue.milestone.title}")
# Labels
if issue.labels:
labels_by_category = {}
for label in issue.labels:
category = label.category
if category not in labels_by_category:
labels_by_category[category] = []
labels_by_category[category].append(label.name)
for category, label_names in labels_by_category.items():
lines.append(f"{category.title()} labels: {', '.join(label_names)}")
else:
lines.append("Labels: none")
# Description
lines.append("")
lines.append("Description:")
lines.append("-" * 12)
if issue.description:
lines.append(issue.description)
else:
lines.append("(no description)")
# Comments
if show_comments and backend:
comments = backend.get_comments(str(issue.number))
if comments:
lines.append("")
lines.append(f"Comments ({len(comments)}):")
lines.append("-" * 20)
for comment in comments:
lines.append("")
lines.append(f"Comment by {comment.author.username} at {format_datetime(comment.created_at)}:")
lines.append(comment.body)
return "\n".join(lines)
def format_datetime(dt: datetime) -> str:
"""Format datetime for display."""
if dt.tzinfo:
dt = dt.astimezone()
return dt.strftime("%Y-%m-%d %H:%M:%S")
def get_user_input(prompt: str, default: str = "", multiline: bool = False) -> str:
"""Get user input with optional default and multiline support."""
if multiline:
click.echo(f"{prompt} (Press Ctrl+D when done, Ctrl+C to cancel):")
if default:
click.echo(f"Current value:\n{default}\n")
lines = []
try:
while True:
try:
line = input()
lines.append(line)
except EOFError:
break
except KeyboardInterrupt:
raise click.Abort()
return "\n".join(lines) if lines else default
else:
return click.prompt(prompt, default=default)
def validate_backend_type(backend_type: str) -> bool:
"""Validate that a backend type is supported."""
return backend_type in BackendFactory.get_available_backends()
def test_backend_connection(backend_config: dict) -> bool:
"""Test if a backend configuration works."""
try:
backend_type = backend_config['type']
backend = BackendFactory.create_backend(backend_type)
backend.connect(backend_config)
result = backend.test_connection()
backend.disconnect()
return result
except Exception:
return False
def format_backend_list(configs: dict) -> str:
"""Format backend configurations for display."""
if not configs:
return "No backends configured."
lines = []
default_backend = configs.get('default', 'local')
lines.append(f"{'Name':<15} {'Type':<10} {'Status':<10} Description")
lines.append("-" * 60)
for name, config in configs.items():
if name == 'default':
continue
backend_type = config.get('type', 'unknown')
is_default = name == default_backend
# Test connection
try:
status = "connected" if test_backend_connection(config) else "error"
except Exception:
status = "error"
# Description
if backend_type == 'local':
desc = f"Local SQLite: {config.get('db_path', 'unknown')}"
elif backend_type == 'gitea':
desc = f"Gitea: {config.get('base_url', 'unknown')}/{config.get('owner', 'unknown')}/{config.get('repo', 'unknown')}"
else:
desc = f"{backend_type} backend"
if is_default:
desc += " (default)"
line = f"{name:<15} {backend_type:<10} {status:<10} {desc}"
lines.append(line)
return "\n".join(lines)
def get_editor() -> str:
"""Get the user's preferred editor."""
return os.environ.get('EDITOR', 'nano')
def edit_text(initial_text: str = "") -> Optional[str]:
"""Open text in editor and return edited content."""
with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as f:
f.write(initial_text)
temp_path = f.name
try:
editor = get_editor()
os.system(f'{editor} {temp_path}')
with open(temp_path, 'r') as f:
edited_text = f.read()
return edited_text if edited_text != initial_text else None
finally:
os.unlink(temp_path)
def confirm_action(message: str, default: bool = False) -> bool:
"""Ask user for confirmation."""
return click.confirm(message, default=default)
def progress_bar(items, label: str = "Processing"):
"""Create a progress bar for iterating over items."""
return click.progressbar(items, label=label)
def echo_success(message: str) -> None:
"""Echo success message in green."""
click.echo(click.style(message, fg='green'))
def echo_warning(message: str) -> None:
"""Echo warning message in yellow."""
click.echo(click.style(message, fg='yellow'))
def echo_error(message: str) -> None:
"""Echo error message in red."""
click.echo(click.style(message, fg='red'))
def echo_info(message: str) -> None:
"""Echo info message in blue."""
click.echo(click.style(message, fg='blue'))