Files
markitect-main/markitect/issues/activity_commands.py
tegwick d49fa8e9fb feat: implement issue activity tracking system (issue #113)
- Add comprehensive IssueActivityTracker service with ActivityType enum and IssueActivity dataclass
- Implement full CLI interface with log, show, list, summary, delete, and import-activities commands
- Support activity logging with automatic period detection and cost allocation integration
- Add activity retrieval by issue, by period, with filtering and pagination
- Include activity summaries with statistics and breakdowns across issues and time periods
- Support bulk operations for activity import from JSON/CSV formats
- Integrate with existing finance schema using cost_periods and issue_activity_log tables
- Add 28 comprehensive test cases covering all functionality with 100% pass rate
- Enable both table and JSON output formats for all CLI commands

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 03:14:04 +02:00

293 lines
12 KiB
Python

"""
CLI commands for issue activity tracking.
This module provides command-line interface for logging, viewing, and managing
issue activities for cost allocation and project management purposes.
"""
import click
from datetime import datetime, date
from typing import List, Optional
from tabulate import tabulate
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType, IssueActivity
@click.group()
def activity():
"""Issue activity tracking commands."""
pass
@activity.command()
@click.argument('issue_id', type=int)
@click.argument('activity_type', type=click.Choice([at.value for at in ActivityType]))
@click.option('--date', '-d', type=click.DateTime(formats=['%Y-%m-%d']),
help='Activity date (defaults to today)')
@click.option('--details', '-m', help='Activity details/message')
@click.option('--period-id', type=int, help='Cost period ID for allocation')
def log(issue_id: int, activity_type: str, date: Optional[datetime],
details: Optional[str], period_id: Optional[int]):
"""Log an activity for an issue."""
tracker = IssueActivityTracker()
activity_date = date.date() if date else None
activity_enum = ActivityType(activity_type)
try:
activity_id = tracker.log_activity(
issue_id=issue_id,
activity_type=activity_enum,
activity_date=activity_date,
activity_details=details,
period_id=period_id
)
click.echo(f"✅ Logged {activity_type} activity for issue #{issue_id} (ID: {activity_id})")
if details:
click.echo(f" Details: {details}")
except Exception as e:
click.echo(f"❌ Error logging activity: {e}", err=True)
raise click.Abort()
@activity.command()
@click.argument('issue_id', type=int)
@click.option('--limit', '-l', type=int, default=20, help='Maximum number of activities to show')
@click.option('--offset', type=int, default=0, help='Number of activities to skip')
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
default='table', help='Output format')
def show(issue_id: int, limit: int, offset: int, output_format: str):
"""Show activities for a specific issue."""
tracker = IssueActivityTracker()
try:
activities = tracker.get_issue_activities(issue_id, limit=limit, offset=offset)
if not activities:
click.echo(f"📝 No activities found for issue #{issue_id}")
return
if output_format == 'json':
import json
activity_data = []
for activity in activities:
data = {
'id': activity.id,
'issue_id': activity.issue_id,
'activity_type': activity.activity_type.value,
'activity_date': activity.activity_date.isoformat() if activity.activity_date else None,
'period_id': activity.period_id,
'activity_details': activity.activity_details,
'created_at': activity.created_at.isoformat() if activity.created_at else None
}
activity_data.append(data)
click.echo(json.dumps(activity_data, indent=2))
else:
# Table format
click.echo(f"\n📋 Activities for Issue #{issue_id}\n")
headers = ['ID', 'Type', 'Date', 'Period', 'Details', 'Logged']
rows = []
for activity in activities:
rows.append([
activity.id,
activity.activity_type.value.title(),
activity.activity_date.strftime('%Y-%m-%d') if activity.activity_date else 'N/A',
activity.period_id or 'N/A',
(activity.activity_details[:40] + '...') if activity.activity_details and len(activity.activity_details) > 40 else (activity.activity_details or ''),
activity.created_at.strftime('%Y-%m-%d %H:%M') if activity.created_at else 'N/A'
])
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
if len(activities) == limit:
click.echo(f"\n💡 Showing {limit} most recent activities. Use --limit and --offset for pagination.")
except Exception as e:
click.echo(f"❌ Error retrieving activities: {e}", err=True)
raise click.Abort()
@activity.command()
@click.option('--period-id', type=int, help='Filter by cost period ID')
@click.option('--activity-type', type=click.Choice([at.value for at in ActivityType]),
multiple=True, help='Filter by activity types (can specify multiple)')
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
default='table', help='Output format')
def list(period_id: Optional[int], activity_type: List[str], output_format: str):
"""List activities across issues."""
tracker = IssueActivityTracker()
try:
if period_id:
activity_types = [ActivityType(at) for at in activity_type] if activity_type else None
activities = tracker.get_activities_by_period(period_id, activity_types)
title = f"Activities for Period #{period_id}"
else:
# For now, show recent activities across all issues (could be enhanced)
click.echo("❌ Currently only period-based listing is supported. Use --period-id option.")
return
if not activities:
click.echo(f"📝 No activities found for the specified criteria")
return
if output_format == 'json':
import json
activity_data = []
for activity in activities:
data = {
'id': activity.id,
'issue_id': activity.issue_id,
'activity_type': activity.activity_type.value,
'activity_date': activity.activity_date.isoformat() if activity.activity_date else None,
'period_id': activity.period_id,
'activity_details': activity.activity_details,
'created_at': activity.created_at.isoformat() if activity.created_at else None
}
activity_data.append(data)
click.echo(json.dumps(activity_data, indent=2))
else:
# Table format
click.echo(f"\n📊 {title}\n")
headers = ['ID', 'Issue', 'Type', 'Date', 'Details']
rows = []
for activity in activities:
rows.append([
activity.id,
f"#{activity.issue_id}",
activity.activity_type.value.title(),
activity.activity_date.strftime('%Y-%m-%d') if activity.activity_date else 'N/A',
(activity.activity_details[:50] + '...') if activity.activity_details and len(activity.activity_details) > 50 else (activity.activity_details or '')
])
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
click.echo(f"\n📈 Total: {len(activities)} activities")
except Exception as e:
click.echo(f"❌ Error retrieving activities: {e}", err=True)
raise click.Abort()
@activity.command()
@click.option('--issue-id', type=int, help='Filter by specific issue ID')
@click.option('--start-date', type=click.DateTime(formats=['%Y-%m-%d']),
help='Start date for summary period')
@click.option('--end-date', type=click.DateTime(formats=['%Y-%m-%d']),
help='End date for summary period')
def summary(issue_id: Optional[int], start_date: Optional[datetime],
end_date: Optional[datetime]):
"""Show activity summary statistics."""
tracker = IssueActivityTracker()
try:
summary_data = tracker.get_activity_summary(
issue_id=issue_id,
start_date=start_date.date() if start_date else None,
end_date=end_date.date() if end_date else None
)
click.echo("\n📊 Issue Activity Summary\n")
# Basic stats
click.echo(f"Total Activities: {summary_data['total_activities']}")
click.echo(f"Unique Issues: {summary_data['unique_issues']}")
# Date range
date_range = summary_data['date_range']
if date_range['start'] and date_range['end']:
click.echo(f"Date Range: {date_range['start']} to {date_range['end']}")
elif date_range['start']:
click.echo(f"Since: {date_range['start']}")
# Activity breakdown
if summary_data['activities_by_type']:
click.echo("\nActivity Breakdown:")
for activity_type, count in summary_data['activities_by_type'].items():
percentage = (count / summary_data['total_activities']) * 100
click.echo(f" {activity_type.title()}: {count} ({percentage:.1f}%)")
# Filters applied
filters = summary_data['filters']
applied_filters = []
if filters['issue_id']:
applied_filters.append(f"Issue #{filters['issue_id']}")
if filters['start_date']:
applied_filters.append(f"From {filters['start_date']}")
if filters['end_date']:
applied_filters.append(f"Until {filters['end_date']}")
if applied_filters:
click.echo(f"\nFilters Applied: {', '.join(applied_filters)}")
except Exception as e:
click.echo(f"❌ Error generating summary: {e}", err=True)
raise click.Abort()
@activity.command()
@click.argument('activity_id', type=int)
@click.confirmation_option(prompt='Are you sure you want to delete this activity?')
def delete(activity_id: int):
"""Delete an activity record."""
tracker = IssueActivityTracker()
try:
if tracker.delete_activity(activity_id):
click.echo(f"✅ Deleted activity #{activity_id}")
else:
click.echo(f"❌ Activity #{activity_id} not found")
raise click.Abort()
except Exception as e:
click.echo(f"❌ Error deleting activity: {e}", err=True)
raise click.Abort()
@activity.command()
@click.argument('file_path', type=click.Path(exists=True))
@click.option('--format', 'input_format', type=click.Choice(['json', 'csv']),
default='json', help='Input file format')
def import_activities(file_path: str, input_format: str):
"""Import activities from a file."""
tracker = IssueActivityTracker()
try:
if input_format == 'json':
import json
with open(file_path, 'r') as f:
activities = json.load(f)
elif input_format == 'csv':
import csv
activities = []
with open(file_path, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
activity = {
'issue_id': int(row['issue_id']),
'activity_type': row['activity_type'],
'activity_date': datetime.strptime(row['activity_date'], '%Y-%m-%d').date() if row.get('activity_date') else None,
'activity_details': row.get('activity_details'),
'period_id': int(row['period_id']) if row.get('period_id') else None
}
activities.append(activity)
activity_ids = tracker.bulk_log_activities(activities)
click.echo(f"✅ Successfully imported {len(activity_ids)} activities")
except Exception as e:
click.echo(f"❌ Error importing activities: {e}", err=True)
raise click.Abort()
if __name__ == '__main__':
activity()