Automated issue wrap-up including: - Implementation completion verification - Test execution and validation - Cost tracking and note generation - Repository state commit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
510 lines
19 KiB
Python
510 lines
19 KiB
Python
"""
|
|
CLI commands for worktime tracking and cost distribution.
|
|
|
|
This module provides command-line interface for logging, viewing, and managing
|
|
worktime entries, estimating daily effort, and distributing costs based on
|
|
time allocation across issues.
|
|
"""
|
|
|
|
import click
|
|
from datetime import datetime, date, timedelta
|
|
from typing import List, Optional
|
|
from tabulate import tabulate
|
|
import json
|
|
|
|
from .worktime_tracker import WorktimeTracker, WorktimeEntry
|
|
|
|
|
|
@click.group()
|
|
def worktime():
|
|
"""Worktime tracking and cost distribution commands."""
|
|
pass
|
|
|
|
|
|
@worktime.command()
|
|
@click.argument('issue_id', type=int)
|
|
@click.argument('duration', type=str)
|
|
@click.option('--date', '-d', type=click.DateTime(formats=['%Y-%m-%d']),
|
|
help='Work date (defaults to today)')
|
|
@click.option('--start', '-s', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']),
|
|
help='Start time (e.g., 09:30 or 2025-10-04 09:30)')
|
|
@click.option('--end', '-e', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']),
|
|
help='End time (e.g., 11:30 or 2025-10-04 11:30)')
|
|
@click.option('--description', '-m', help='Work description')
|
|
@click.option('--type', 'entry_type',
|
|
type=click.Choice(['manual', 'estimated', 'tracked']),
|
|
default='manual', help='Entry type')
|
|
def log(issue_id: int, duration: str, date: Optional[datetime],
|
|
start: Optional[datetime], end: Optional[datetime],
|
|
description: Optional[str], entry_type: str):
|
|
"""Log worktime for an issue.
|
|
|
|
DURATION can be:
|
|
- Minutes: 90, 120
|
|
- Hours and minutes: 1h30m, 2h15m, 1.5h
|
|
- Hours only: 1h, 2.5h
|
|
"""
|
|
tracker = WorktimeTracker()
|
|
|
|
# Parse duration
|
|
try:
|
|
duration_minutes = _parse_duration(duration)
|
|
except ValueError as e:
|
|
click.echo(f"❌ Invalid duration format: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
# Import date module locally to avoid conflict with parameter name
|
|
from datetime import date as date_module
|
|
work_date = date.date() if date else None
|
|
|
|
try:
|
|
entry_id = tracker.log_worktime(
|
|
issue_id=issue_id,
|
|
duration_minutes=duration_minutes,
|
|
work_date=work_date,
|
|
start_time=start,
|
|
end_time=end,
|
|
description=description,
|
|
entry_type=entry_type
|
|
)
|
|
|
|
click.echo(f"✅ Logged {duration_minutes}min worktime for issue #{issue_id} (ID: {entry_id})")
|
|
|
|
if description:
|
|
click.echo(f" Description: {description}")
|
|
|
|
# Show total time for the day
|
|
summary = tracker.get_daily_summary(work_date or date_module.today())
|
|
if summary:
|
|
hours = summary.total_minutes // 60
|
|
minutes = summary.total_minutes % 60
|
|
click.echo(f"📊 Daily total: {hours}h {minutes}m across {summary.issue_count} issues")
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error logging worktime: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.option('--issue', type=int, help='Filter by specific issue ID')
|
|
@click.option('--date', type=click.DateTime(formats=['%Y-%m-%d']),
|
|
help='Filter by specific date')
|
|
@click.option('--start-date', type=click.DateTime(formats=['%Y-%m-%d']),
|
|
help='Start date for range filter')
|
|
@click.option('--end-date', type=click.DateTime(formats=['%Y-%m-%d']),
|
|
help='End date for range filter')
|
|
@click.option('--limit', '-l', type=int, default=20,
|
|
help='Maximum number of entries to show')
|
|
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
|
|
default='table', help='Output format')
|
|
def list(issue: Optional[int], date: Optional[datetime],
|
|
start_date: Optional[datetime], end_date: Optional[datetime],
|
|
limit: int, output_format: str):
|
|
"""List worktime entries with optional filtering."""
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
entries = tracker.get_worktime_entries(
|
|
issue_id=issue,
|
|
work_date=date.date() if date else None,
|
|
start_date=start_date.date() if start_date else None,
|
|
end_date=end_date.date() if end_date else None,
|
|
limit=limit
|
|
)
|
|
|
|
if not entries:
|
|
click.echo("📝 No worktime entries found for the specified criteria")
|
|
return
|
|
|
|
if output_format == 'json':
|
|
entry_data = []
|
|
for entry in entries:
|
|
data = {
|
|
'id': entry.id,
|
|
'issue_id': entry.issue_id,
|
|
'work_date': entry.work_date.isoformat() if entry.work_date else None,
|
|
'start_time': entry.start_time.isoformat() if entry.start_time else None,
|
|
'end_time': entry.end_time.isoformat() if entry.end_time else None,
|
|
'duration_minutes': entry.duration_minutes,
|
|
'duration_formatted': _format_duration(entry.duration_minutes),
|
|
'description': entry.description,
|
|
'entry_type': entry.entry_type,
|
|
'created_at': entry.created_at.isoformat() if entry.created_at else None
|
|
}
|
|
entry_data.append(data)
|
|
click.echo(json.dumps(entry_data, indent=2))
|
|
|
|
else:
|
|
# Table format
|
|
click.echo(f"\n⏰ Worktime Entries ({len(entries)} found)\n")
|
|
|
|
headers = ['ID', 'Issue', 'Date', 'Duration', 'Start', 'End', 'Type', 'Description']
|
|
rows = []
|
|
|
|
for entry in entries:
|
|
rows.append([
|
|
entry.id,
|
|
f"#{entry.issue_id}",
|
|
entry.work_date.strftime('%Y-%m-%d') if entry.work_date else 'N/A',
|
|
_format_duration(entry.duration_minutes),
|
|
entry.start_time.strftime('%H:%M') if entry.start_time else 'N/A',
|
|
entry.end_time.strftime('%H:%M') if entry.end_time else 'N/A',
|
|
entry.entry_type.title(),
|
|
(entry.description[:30] + '...') if entry.description and len(entry.description) > 30 else (entry.description or '')
|
|
])
|
|
|
|
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
|
|
|
if len(entries) == limit:
|
|
click.echo(f"\n💡 Showing {limit} most recent entries. Use --limit to see more.")
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error retrieving entries: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']))
|
|
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
|
|
default='table', help='Output format')
|
|
def daily(date: datetime, output_format: str):
|
|
"""Show daily worktime summary for a specific date."""
|
|
from datetime import date as date_module
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
summary = tracker.get_daily_summary(date.date())
|
|
|
|
if not summary:
|
|
click.echo(f"📅 No worktime entries found for {date.date()}")
|
|
return
|
|
|
|
if output_format == 'json':
|
|
data = {
|
|
'work_date': summary.work_date.isoformat(),
|
|
'total_minutes': summary.total_minutes,
|
|
'total_formatted': _format_duration(summary.total_minutes),
|
|
'issue_count': summary.issue_count,
|
|
'cost_per_minute': float(summary.cost_per_minute) if summary.cost_per_minute else None,
|
|
'total_cost_allocated': float(summary.total_cost_allocated) if summary.total_cost_allocated else None,
|
|
'entries': [
|
|
{
|
|
'issue_id': entry.issue_id,
|
|
'duration_minutes': entry.duration_minutes,
|
|
'duration_formatted': _format_duration(entry.duration_minutes),
|
|
'description': entry.description,
|
|
'entry_type': entry.entry_type
|
|
}
|
|
for entry in summary.entries
|
|
]
|
|
}
|
|
click.echo(json.dumps(data, indent=2))
|
|
|
|
else:
|
|
# Table format
|
|
click.echo(f"\n📅 Daily Summary for {summary.work_date}\n")
|
|
|
|
click.echo(f"Total Time: {_format_duration(summary.total_minutes)} ({summary.total_minutes} minutes)")
|
|
click.echo(f"Issues Worked: {summary.issue_count}")
|
|
|
|
if summary.cost_per_minute:
|
|
click.echo(f"Cost per Minute: €{summary.cost_per_minute:.4f}")
|
|
if summary.total_cost_allocated:
|
|
click.echo(f"Total Cost Allocated: €{summary.total_cost_allocated:.2f}")
|
|
|
|
click.echo("\nBreakdown by Issue:")
|
|
|
|
# Group by issue
|
|
issue_breakdown = {}
|
|
for entry in summary.entries:
|
|
if entry.issue_id not in issue_breakdown:
|
|
issue_breakdown[entry.issue_id] = 0
|
|
issue_breakdown[entry.issue_id] += entry.duration_minutes
|
|
|
|
headers = ['Issue', 'Time', 'Percentage']
|
|
rows = []
|
|
|
|
for issue_id, minutes in sorted(issue_breakdown.items()):
|
|
percentage = (minutes / summary.total_minutes) * 100
|
|
rows.append([
|
|
f"#{issue_id}",
|
|
_format_duration(minutes),
|
|
f"{percentage:.1f}%"
|
|
])
|
|
|
|
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error generating summary: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']))
|
|
@click.argument('hours', type=float)
|
|
@click.option('--issues', '-i', multiple=True, type=int,
|
|
help='Issues that were worked on (can specify multiple)')
|
|
@click.option('--method', type=click.Choice(['equal', 'activity_based']),
|
|
default='equal', help='Distribution method')
|
|
def estimate(date: datetime, hours: float, issues: List[int], method: str):
|
|
"""Estimate worktime distribution for a day."""
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
# Convert issues tuple to list safely
|
|
issues_list = [int(issue) for issue in issues] if issues else None
|
|
|
|
result = tracker.estimate_daily_worktime(
|
|
work_date=date.date(),
|
|
total_hours=hours,
|
|
issues=issues_list,
|
|
distribution_method=method
|
|
)
|
|
|
|
click.echo(f"\n📊 Worktime Estimation for {result['work_date']}")
|
|
click.echo(f"Total Hours: {hours}h ({result['total_minutes']} minutes)")
|
|
click.echo(f"Distribution Method: {method}")
|
|
click.echo(f"Issues: {result['issues_count']}")
|
|
|
|
click.echo("\nEstimated Time per Issue:")
|
|
headers = ['Issue', 'Time', 'Percentage']
|
|
rows = []
|
|
|
|
for issue_id, minutes in result['issue_estimates'].items():
|
|
percentage = (minutes / result['total_minutes']) * 100
|
|
rows.append([
|
|
f"#{issue_id}",
|
|
_format_duration(minutes),
|
|
f"{percentage:.1f}%"
|
|
])
|
|
|
|
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
|
click.echo(f"\n✅ Created {len(result['issue_estimates'])} estimated worktime entries")
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error creating estimation: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']))
|
|
@click.argument('total_cost', type=float)
|
|
@click.option('--period-id', type=int, help='Cost period ID for tracking')
|
|
def distribute(date: datetime, total_cost: float, period_id: Optional[int]):
|
|
"""Distribute daily costs based on time allocation."""
|
|
from datetime import date as date_module
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
from decimal import Decimal
|
|
result = tracker.distribute_daily_costs(
|
|
work_date=date.date(),
|
|
total_daily_cost=Decimal(str(total_cost)),
|
|
period_id=period_id
|
|
)
|
|
|
|
if 'message' in result:
|
|
click.echo(f"⚠️ {result['message']}")
|
|
return
|
|
|
|
click.echo(f"\n💰 Cost Distribution for {result['work_date']}")
|
|
click.echo(f"Total Cost: €{result['total_cost']:.2f}")
|
|
click.echo(f"Total Time: {_format_duration(result['total_minutes'])}")
|
|
click.echo(f"Cost per Minute: €{result['cost_per_minute']:.4f}")
|
|
|
|
click.echo("\nCost Allocation by Issue:")
|
|
headers = ['Issue', 'Time', 'Percentage', 'Cost Allocated']
|
|
rows = []
|
|
|
|
for issue_id, distribution in result['distributions'].items():
|
|
rows.append([
|
|
f"#{issue_id}",
|
|
_format_duration(distribution['minutes']),
|
|
f"{distribution['percentage']:.1f}%",
|
|
f"€{distribution['cost_allocated']:.2f}"
|
|
])
|
|
|
|
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
|
|
|
if period_id:
|
|
click.echo(f"\n✅ Cost distribution logged to period #{period_id}")
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error distributing costs: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.option('--start-date', type=click.DateTime(formats=['%Y-%m-%d']),
|
|
help='Report start date (defaults to 30 days ago)')
|
|
@click.option('--end-date', type=click.DateTime(formats=['%Y-%m-%d']),
|
|
help='Report end date (defaults to today)')
|
|
@click.option('--issue', type=int, help='Filter by specific issue ID')
|
|
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
|
|
default='table', help='Output format')
|
|
def report(start_date: Optional[datetime], end_date: Optional[datetime],
|
|
issue: Optional[int], output_format: str):
|
|
"""Generate comprehensive worktime report."""
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
result = tracker.get_worktime_report(
|
|
start_date=start_date.date() if start_date else None,
|
|
end_date=end_date.date() if end_date else None,
|
|
issue_id=issue
|
|
)
|
|
|
|
if output_format == 'json':
|
|
click.echo(json.dumps(result, indent=2))
|
|
return
|
|
|
|
# Table format
|
|
click.echo(f"\n📈 Worktime Report")
|
|
click.echo(f"Period: {result['period']}")
|
|
click.echo(f"Total Entries: {result['total_entries']}")
|
|
click.echo(f"Total Time: {result['total_time']['hours']}h {result['total_time']['minutes']}m")
|
|
click.echo(f"Unique Issues: {result['unique_issues']}")
|
|
click.echo(f"Unique Dates: {result['unique_dates']}")
|
|
|
|
if result['unique_dates'] > 0:
|
|
avg_minutes = result['average_minutes_per_day']
|
|
avg_hours = int(avg_minutes // 60)
|
|
avg_mins = int(avg_minutes % 60)
|
|
click.echo(f"Average per Day: {avg_hours}h {avg_mins}m")
|
|
|
|
if result.get('issue_breakdown'):
|
|
click.echo("\nTime by Issue:")
|
|
headers = ['Issue', 'Total Time', 'Entries', 'Days', 'Avg/Day']
|
|
rows = []
|
|
|
|
for issue_id, data in sorted(result['issue_breakdown'].items()):
|
|
total_time = _format_duration(data['total_minutes'])
|
|
avg_per_day = data['total_minutes'] / data['unique_dates']
|
|
avg_formatted = _format_duration(int(avg_per_day))
|
|
|
|
rows.append([
|
|
f"#{issue_id}",
|
|
total_time,
|
|
data['entry_count'],
|
|
data['unique_dates'],
|
|
avg_formatted
|
|
])
|
|
|
|
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error generating report: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.argument('entry_id', type=int)
|
|
@click.confirmation_option(prompt='Are you sure you want to delete this worktime entry?')
|
|
def delete(entry_id: int):
|
|
"""Delete a worktime entry."""
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
if tracker.delete_worktime_entry(entry_id):
|
|
click.echo(f"✅ Deleted worktime entry #{entry_id}")
|
|
else:
|
|
click.echo(f"❌ Worktime entry #{entry_id} not found")
|
|
raise click.Abort()
|
|
|
|
except Exception as e:
|
|
click.echo(f"❌ Error deleting entry: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
@worktime.command()
|
|
@click.argument('entry_id', type=int)
|
|
@click.option('--duration', type=str, help='New duration (e.g., 90, 1h30m, 2.5h)')
|
|
@click.option('--description', help='New description')
|
|
@click.option('--start', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']),
|
|
help='New start time')
|
|
@click.option('--end', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']),
|
|
help='New end time')
|
|
def update(entry_id: int, duration: Optional[str], description: Optional[str],
|
|
start: Optional[datetime], end: Optional[datetime]):
|
|
"""Update a worktime entry."""
|
|
tracker = WorktimeTracker()
|
|
|
|
try:
|
|
duration_minutes = None
|
|
if duration:
|
|
duration_minutes = _parse_duration(duration)
|
|
|
|
success = tracker.update_worktime_entry(
|
|
entry_id=entry_id,
|
|
duration_minutes=duration_minutes,
|
|
description=description,
|
|
start_time=start,
|
|
end_time=end
|
|
)
|
|
|
|
if success:
|
|
click.echo(f"✅ Updated worktime entry #{entry_id}")
|
|
else:
|
|
click.echo(f"❌ Worktime entry #{entry_id} not found or no changes made")
|
|
raise click.Abort()
|
|
|
|
except ValueError as e:
|
|
click.echo(f"❌ Invalid duration format: {e}", err=True)
|
|
raise click.Abort()
|
|
except Exception as e:
|
|
click.echo(f"❌ Error updating entry: {e}", err=True)
|
|
raise click.Abort()
|
|
|
|
|
|
def _parse_duration(duration_str: str) -> int:
|
|
"""Parse duration string into minutes."""
|
|
duration_str = duration_str.lower().strip()
|
|
|
|
# Handle pure numbers (assume minutes)
|
|
if duration_str.isdigit():
|
|
return int(duration_str)
|
|
|
|
# Handle decimal hours (e.g., 1.5h, 2.25h)
|
|
if duration_str.endswith('h') and '.' in duration_str:
|
|
try:
|
|
hours = float(duration_str[:-1])
|
|
return int(hours * 60)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Handle hours and minutes (e.g., 1h30m, 2h15m)
|
|
if 'h' in duration_str:
|
|
parts = duration_str.split('h')
|
|
hours = int(parts[0]) if parts[0] else 0
|
|
minutes = 0
|
|
|
|
if len(parts) > 1 and parts[1]:
|
|
minute_part = parts[1].replace('m', '').strip()
|
|
if minute_part:
|
|
minutes = int(minute_part)
|
|
|
|
return hours * 60 + minutes
|
|
|
|
# Handle minutes only (e.g., 90m)
|
|
if duration_str.endswith('m'):
|
|
return int(duration_str[:-1])
|
|
|
|
raise ValueError(f"Unable to parse duration: {duration_str}. Use formats like: 90, 1h30m, 2.5h")
|
|
|
|
|
|
def _format_duration(minutes: int) -> str:
|
|
"""Format minutes into human-readable duration."""
|
|
if minutes < 60:
|
|
return f"{minutes}m"
|
|
|
|
hours = minutes // 60
|
|
remaining_minutes = minutes % 60
|
|
|
|
if remaining_minutes == 0:
|
|
return f"{hours}h"
|
|
else:
|
|
return f"{hours}h{remaining_minutes}m"
|
|
|
|
|
|
if __name__ == '__main__':
|
|
worktime() |