generated from coulomb/repo-seed
Updated by fix-consistency on 2026-05-17: - update .custodian-brief.md for issue-core
422 lines
16 KiB
Python
422 lines
16 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
|
|
# 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}") |