Files
issue-core/cli/commands.py

417 lines
15 KiB
Python

"""
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}")