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:
422
issue_tracker/cli/commands.py
Normal file
422
issue_tracker/cli/commands.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Issue Management CLI Commands
|
||||
|
||||
Core commands for managing issues: create, list, show, edit, close, etc.
|
||||
"""
|
||||
|
||||
import click
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from ..core.models import Issue, Label, User, IssueState, Priority, IssueType
|
||||
from ..core.interfaces import IssueFilter
|
||||
from .utils import get_backend, format_issue, format_issue_list, get_user_input
|
||||
|
||||
|
||||
@click.group()
|
||||
def issue_group():
|
||||
"""Issue management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@issue_group.command('list')
|
||||
@click.option('--state', type=click.Choice(['open', 'closed', 'all']), default='open', help='Issue state filter')
|
||||
@click.option('--assignee', help='Filter by assignee')
|
||||
@click.option('--label', multiple=True, help='Filter by labels')
|
||||
@click.option('--milestone', help='Filter by milestone')
|
||||
@click.option('--search', help='Search in title and description')
|
||||
@click.option('--limit', type=int, default=30, help='Maximum number of issues to show')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['table', 'json', 'compact']), default='table', help='Output format')
|
||||
@click.pass_context
|
||||
def list_issues(ctx, state, assignee, label, milestone, search, limit, output_format):
|
||||
"""List issues with optional filtering."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
# Build filter criteria
|
||||
# Handle Click Sentinel values
|
||||
search_value = search if search is not None and not str(search).startswith('Sentinel') else None
|
||||
assignee_value = assignee if assignee is not None and not str(assignee).startswith('Sentinel') else None
|
||||
milestone_value = milestone if milestone is not None and not str(milestone).startswith('Sentinel') else None
|
||||
|
||||
filter_criteria = IssueFilter(
|
||||
state=None if state == 'all' else state,
|
||||
assignee=assignee_value,
|
||||
labels=list(label) if label else None,
|
||||
milestone=milestone_value,
|
||||
search=search_value,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
try:
|
||||
issues = backend.list_issues(filter_criteria)
|
||||
|
||||
if not issues:
|
||||
click.echo("No issues found.")
|
||||
return
|
||||
|
||||
if output_format == 'json':
|
||||
import json
|
||||
click.echo(json.dumps([issue.to_dict() for issue in issues], indent=2))
|
||||
elif output_format == 'compact':
|
||||
for issue in issues:
|
||||
labels_str = ', '.join(label.name for label in issue.labels[:3])
|
||||
if len(issue.labels) > 3:
|
||||
labels_str += f' (+{len(issue.labels) - 3} more)'
|
||||
|
||||
assignee_str = issue.primary_assignee.username if issue.primary_assignee else 'unassigned'
|
||||
|
||||
click.echo(f"#{issue.number:4d} {issue.state.value:10s} {issue.title[:50]:50s} {assignee_str:15s} {labels_str}")
|
||||
else: # table format
|
||||
click.echo(format_issue_list(issues))
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to list issues: {e}")
|
||||
|
||||
|
||||
@issue_group.command('show')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comments', is_flag=True, help='Show comments')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['detailed', 'json', 'compact']), default='detailed', help='Output format')
|
||||
@click.pass_context
|
||||
def show_issue(ctx, issue_number, comments, output_format):
|
||||
"""Show detailed information about an issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
if output_format == 'json':
|
||||
import json
|
||||
issue_dict = issue.to_dict()
|
||||
if comments:
|
||||
issue_dict['comments'] = [
|
||||
{
|
||||
'id': c.id,
|
||||
'body': c.body,
|
||||
'author': c.author.username,
|
||||
'created_at': c.created_at.isoformat()
|
||||
}
|
||||
for c in backend.get_comments(str(issue.number))
|
||||
]
|
||||
click.echo(json.dumps(issue_dict, indent=2))
|
||||
else:
|
||||
click.echo(format_issue(issue, show_comments=comments, backend=backend if comments else None))
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to show issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('create')
|
||||
@click.argument('title')
|
||||
@click.option('--description', '-d', help='Issue description')
|
||||
@click.option('--label', '-l', multiple=True, help='Labels to add')
|
||||
@click.option('--assignee', '-a', help='Assign to user')
|
||||
@click.option('--milestone', '-m', help='Milestone')
|
||||
@click.option('--priority', type=click.Choice(['low', 'medium', 'high', 'critical']), help='Issue priority')
|
||||
@click.option('--type', 'issue_type', type=click.Choice(['bug', 'feature', 'enhancement', 'task', 'documentation', 'question']), help='Issue type')
|
||||
@click.option('--interactive', '-i', is_flag=True, help='Interactive mode')
|
||||
@click.pass_context
|
||||
def create_issue(ctx, title, description, label, assignee, milestone, priority, issue_type, interactive):
|
||||
"""Create a new issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
# Interactive mode
|
||||
if interactive:
|
||||
title = title or click.prompt('Title')
|
||||
description = get_user_input('Description (optional)', multiline=True)
|
||||
|
||||
# Show available labels
|
||||
available_labels = backend.get_labels()
|
||||
if available_labels:
|
||||
click.echo(f"\nAvailable labels: {', '.join(l.name for l in available_labels)}")
|
||||
label = click.prompt('Labels (comma-separated, optional)', default='').split(',') if click.prompt('Add labels?', type=bool, default=False) else []
|
||||
|
||||
# Show available users
|
||||
available_users = backend.get_users()
|
||||
if available_users:
|
||||
click.echo(f"\nAvailable users: {', '.join(u.username for u in available_users)}")
|
||||
assignee = click.prompt('Assignee (optional)', default='') or None
|
||||
|
||||
# Show available milestones
|
||||
available_milestones = backend.get_milestones()
|
||||
if available_milestones:
|
||||
click.echo(f"\nAvailable milestones: {', '.join(m.title for m in available_milestones)}")
|
||||
milestone = click.prompt('Milestone (optional)', default='') or None
|
||||
|
||||
# Build labels list
|
||||
labels = []
|
||||
|
||||
# Add explicit labels
|
||||
for label_name in label:
|
||||
label_name = label_name.strip()
|
||||
if label_name:
|
||||
labels.append(Label(name=label_name))
|
||||
|
||||
# Add priority label
|
||||
if priority:
|
||||
labels.append(Label(name=f'priority:{priority}'))
|
||||
|
||||
# Add type label
|
||||
if issue_type:
|
||||
labels.append(Label(name=issue_type))
|
||||
|
||||
# Build assignees list
|
||||
assignees = []
|
||||
if assignee:
|
||||
# Try to find user
|
||||
users = backend.search_users(assignee)
|
||||
if users:
|
||||
assignees.append(users[0])
|
||||
else:
|
||||
# Create a basic user object
|
||||
assignees.append(User(id=assignee, username=assignee))
|
||||
|
||||
# Find milestone
|
||||
milestone_obj = None
|
||||
if milestone:
|
||||
milestones = backend.get_milestones()
|
||||
for m in milestones:
|
||||
if m.title == milestone or m.id == milestone:
|
||||
milestone_obj = m
|
||||
break
|
||||
|
||||
# Create issue
|
||||
now = datetime.now(timezone.utc)
|
||||
issue = Issue(
|
||||
id="", # Will be set by backend
|
||||
number=0, # Will be set by backend
|
||||
title=title,
|
||||
description=description or "",
|
||||
state=IssueState.OPEN,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
labels=labels,
|
||||
assignees=assignees,
|
||||
milestone=milestone_obj
|
||||
)
|
||||
|
||||
created_issue = backend.create_issue(issue)
|
||||
click.echo(f"Created issue #{created_issue.number}: {created_issue.title}")
|
||||
|
||||
if ctx.obj.get('verbose'):
|
||||
click.echo(format_issue(created_issue))
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to create issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('edit')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--title', help='New title')
|
||||
@click.option('--description', help='New description')
|
||||
@click.option('--add-label', multiple=True, help='Labels to add')
|
||||
@click.option('--remove-label', multiple=True, help='Labels to remove')
|
||||
@click.option('--assign', help='User to assign')
|
||||
@click.option('--unassign', help='User to unassign')
|
||||
@click.option('--milestone', help='Milestone to set')
|
||||
@click.option('--interactive', '-i', is_flag=True, help='Interactive editing')
|
||||
@click.pass_context
|
||||
def edit_issue(ctx, issue_number, title, description, add_label, remove_label, assign, unassign, milestone, interactive):
|
||||
"""Edit an existing issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
# Interactive mode
|
||||
if interactive:
|
||||
click.echo(f"Editing issue #{issue.number}: {issue.title}")
|
||||
|
||||
new_title = click.prompt('Title', default=issue.title)
|
||||
if new_title != issue.title:
|
||||
title = new_title
|
||||
|
||||
new_description = get_user_input('Description', default=issue.description, multiline=True)
|
||||
if new_description != issue.description:
|
||||
description = new_description
|
||||
|
||||
# Apply changes
|
||||
modified = False
|
||||
|
||||
if title:
|
||||
issue.title = title
|
||||
modified = True
|
||||
|
||||
if description is not None:
|
||||
issue.description = description
|
||||
modified = True
|
||||
|
||||
# Add labels
|
||||
for label_name in add_label:
|
||||
issue.add_label(Label(name=label_name.strip()))
|
||||
modified = True
|
||||
|
||||
# Remove labels
|
||||
for label_name in remove_label:
|
||||
if issue.remove_label(label_name.strip()):
|
||||
modified = True
|
||||
|
||||
# Assign user
|
||||
if assign:
|
||||
users = backend.search_users(assign)
|
||||
if users:
|
||||
issue.add_assignee(users[0])
|
||||
modified = True
|
||||
else:
|
||||
issue.add_assignee(User(id=assign, username=assign))
|
||||
modified = True
|
||||
|
||||
# Unassign user
|
||||
if unassign:
|
||||
if issue.remove_assignee(unassign):
|
||||
modified = True
|
||||
|
||||
# Set milestone
|
||||
if milestone:
|
||||
milestones = backend.get_milestones()
|
||||
for m in milestones:
|
||||
if m.title == milestone or m.id == milestone:
|
||||
issue.milestone = m
|
||||
modified = True
|
||||
break
|
||||
|
||||
if modified:
|
||||
issue.updated_at = datetime.now(timezone.utc)
|
||||
updated_issue = backend.update_issue(issue)
|
||||
click.echo(f"Updated issue #{updated_issue.number}")
|
||||
|
||||
if ctx.obj.get('verbose'):
|
||||
click.echo(format_issue(updated_issue))
|
||||
else:
|
||||
click.echo("No changes made")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to edit issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('close')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comment', '-c', help='Closing comment')
|
||||
@click.pass_context
|
||||
def close_issue(ctx, issue_number, comment):
|
||||
"""Close an issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
if issue.state == IssueState.CLOSED:
|
||||
click.echo(f"Issue #{issue_number} is already closed")
|
||||
return
|
||||
|
||||
# Close the issue
|
||||
issue.close()
|
||||
|
||||
# Add closing comment if provided
|
||||
if comment:
|
||||
from ..core.models import Comment
|
||||
current_user = User(id="cli-user", username="cli-user") # TODO: Get actual user
|
||||
closing_comment = Comment(
|
||||
id="",
|
||||
body=comment,
|
||||
author=current_user,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
backend.add_comment(str(issue.number), closing_comment)
|
||||
|
||||
updated_issue = backend.update_issue(issue)
|
||||
click.echo(f"Closed issue #{updated_issue.number}: {updated_issue.title}")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to close issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('reopen')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comment', '-c', help='Reopening comment')
|
||||
@click.pass_context
|
||||
def reopen_issue(ctx, issue_number, comment):
|
||||
"""Reopen a closed issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
if issue.state != IssueState.CLOSED:
|
||||
click.echo(f"Issue #{issue_number} is not closed (current state: {issue.state.value})")
|
||||
return
|
||||
|
||||
# Reopen the issue
|
||||
issue.reopen()
|
||||
|
||||
# Add reopening comment if provided
|
||||
if comment:
|
||||
from ..core.models import Comment
|
||||
current_user = User(id="cli-user", username="cli-user") # TODO: Get actual user
|
||||
reopening_comment = Comment(
|
||||
id="",
|
||||
body=comment,
|
||||
author=current_user,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
backend.add_comment(str(issue.number), reopening_comment)
|
||||
|
||||
updated_issue = backend.update_issue(issue)
|
||||
click.echo(f"Reopened issue #{updated_issue.number}: {updated_issue.title}")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to reopen issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('comment')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.argument('comment_text', required=False)
|
||||
@click.option('--editor', is_flag=True, help='Open editor for comment')
|
||||
@click.pass_context
|
||||
def add_comment(ctx, issue_number, comment_text, editor):
|
||||
"""Add a comment to an issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
# Get comment text
|
||||
if editor:
|
||||
comment_text = click.edit() or ""
|
||||
elif not comment_text:
|
||||
comment_text = get_user_input("Comment", multiline=True)
|
||||
|
||||
if not comment_text.strip():
|
||||
click.echo("Empty comment, aborting")
|
||||
return
|
||||
|
||||
# Create comment
|
||||
from ..core.models import Comment
|
||||
current_user = User(id="cli-user", username="cli-user") # TODO: Get actual user
|
||||
comment = Comment(
|
||||
id="",
|
||||
body=comment_text.strip(),
|
||||
author=current_user,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
added_comment = backend.add_comment(str(issue.number), comment)
|
||||
click.echo(f"Added comment to issue #{issue_number}")
|
||||
|
||||
if ctx.obj.get('verbose'):
|
||||
click.echo(f"\nComment by {added_comment.author.username} at {added_comment.created_at}:")
|
||||
click.echo(added_comment.body)
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to add comment: {e}")
|
||||
Reference in New Issue
Block a user