""" 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 filter_criteria = IssueFilter( state=None if state == 'all' else state, assignee=assignee, labels=list(label) if label else None, milestone=milestone, search=search, 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(issue.id) ] 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(issue.id, 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(issue.id, 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(issue.id, 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}")