feat: add YouTrack API client and invoice generator with documentation

This commit is contained in:
2026-02-21 13:20:43 +05:30
parent 6e06487ca2
commit bb273491fc
5 changed files with 795 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
YouTrack Invoice Generator
Generates invoices from time tracking data in YouTrack projects.
"""
import os
import sys
import json
import argparse
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from youtrack_api import YouTrackAPI
class InvoiceGenerator:
"""Generate invoices from YouTrack time tracking data."""
def __init__(self, api: YouTrackAPI, hourly_rate: float = 100.0):
"""
Initialize invoice generator.
Args:
api: YouTrackAPI instance
hourly_rate: Rate per hour (default $100)
"""
self.api = api
self.hourly_rate = hourly_rate
self.rate_per_half_hour = hourly_rate / 2
def get_project_time_data(self, project_id: str, from_date: Optional[str] = None) -> Dict[str, Any]:
"""
Get time tracking data for a project.
Args:
project_id: Project ID
from_date: Optional start date (not currently supported in REST API)
Returns:
Dictionary with time data per issue
"""
# Build query for project issues
# Note: Date filtering with 'updated:' syntax not supported in REST API
# Use 'updated >= YYYY-MM-DD' format instead
query = f'project: {project_id}'
if from_date:
query += f' updated >= {from_date}'
issues = self.api.get_issues(query=query)
project_data = {
'project': None,
'issues': [],
'total_minutes': 0,
'total_hours': 0,
'total_cost': 0
}
# Get project info
try:
project_data['project'] = self.api.get_project(project_id)
except:
# Fallback: use project name from first issue
if issues:
project_data['project'] = {'name': issues[0].get('project', {}).get('name', project_id)}
for issue in issues:
issue_id = issue.get('id')
issue_with_time = self.api.get_issue_with_work_items(issue_id)
work_items = issue_with_time.get('workItems', [])
total_minutes = sum(
wi.get('duration', {}).get('minutes', 0)
for wi in work_items
)
if total_minutes > 0:
hours = total_minutes / 60
cost = self._calculate_cost(total_minutes)
project_data['issues'].append({
'id': issue_id,
'summary': issue.get('summary', 'No summary'),
'description': issue.get('description', ''),
'work_items': work_items,
'total_minutes': total_minutes,
'total_hours': hours,
'cost': cost
})
project_data['total_minutes'] += total_minutes
project_data['total_cost'] += cost
project_data['total_hours'] = project_data['total_minutes'] / 60
return project_data
def _calculate_cost(self, minutes: int) -> float:
"""
Calculate cost based on time, billed in 30-minute increments.
Args:
minutes: Total minutes
Returns:
Total cost
"""
# Convert to 30-minute increments, round up
half_hour_units = (minutes + 29) // 30
# Minimum 30 minutes (1 half-hour unit)
half_hour_units = max(half_hour_units, 1)
return half_hour_units * self.rate_per_half_hour
def generate_invoice_text(self, project_data: Dict[str, Any], month: Optional[str] = None) -> str:
"""
Generate invoice as plain text (can be printed to PDF).
Args:
project_data: Project data from get_project_time_data()
month: Optional month label (e.g., "January 2026")
Returns:
Invoice text
"""
project = project_data['project'] or {}
project_name = project.get('name', 'Unknown Project')
lines = []
lines.append("=" * 70)
lines.append(f"INVOICE - {project_name}")
if month:
lines.append(f"Period: {month}")
lines.append("")
lines.append("WORK ITEMS")
lines.append("-" * 70)
for issue in project_data['issues']:
lines.append("")
lines.append(f"Task: {issue['summary']}")
lines.append(f"ID: {issue['id']}")
if issue['description']:
desc = issue['description'][:200] + "..." if len(issue['description']) > 200 else issue['description']
lines.append(f"Description: {desc}")
for wi in issue['work_items']:
duration = wi.get('duration', {})
mins = duration.get('minutes', 0)
date = wi.get('date', '')
author = wi.get('author', {}).get('name', 'Unknown')
lines.append(f" - {date}: {mins} min ({author})")
lines.append(f" Task total: {issue['total_hours']:.2f} hours (${issue['cost']:.2f})")
lines.append("")
lines.append("-" * 70)
lines.append(f"TOTAL: {project_data['total_hours']:.2f} hours")
lines.append(f"TOTAL COST: ${project_data['total_cost']:.2f}")
lines.append("=" * 70)
return "\n".join(lines)
def generate_invoice_json(self, project_data: Dict[str, Any], month: Optional[str] = None) -> str:
"""
Generate invoice as JSON for programmatic use.
Args:
project_data: Project data from get_project_time_data()
month: Optional month label
Returns:
JSON string
"""
invoice = {
'project': project_data['project'],
'period': month,
'items': project_data['issues'],
'summary': {
'total_minutes': project_data['total_minutes'],
'total_hours': project_data['total_hours'],
'total_cost': project_data['total_cost']
}
}
return json.dumps(invoice, indent=2)
def main():
"""CLI interface for generating invoices."""
parser = argparse.ArgumentParser(description='YouTrack Invoice Generator')
parser.add_argument('--url', required=True, help='YouTrack instance URL')
parser.add_argument('--token', help='API token (or set YOUTRACK_TOKEN env var)')
parser.add_argument('--project', required=True, help='Project ID to generate invoice for')
parser.add_argument('--from-date', help='Start date (YYYY-MM-DD)')
parser.add_argument('--month', help='Month label (e.g., "January 2026")')
parser.add_argument('--rate', type=float, default=100.0, help='Hourly rate (default: 100)')
parser.add_argument('--format', choices=['text', 'json'], default='text', help='Output format')
args = parser.parse_args()
try:
api = YouTrackAPI(args.url, args.token)
generator = InvoiceGenerator(api, hourly_rate=args.rate)
project_data = generator.get_project_time_data(args.project, args.from_date)
if args.format == 'text':
output = generator.generate_invoice_text(project_data, args.month)
else:
output = generator.generate_invoice_json(project_data, args.month)
print(output)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()