feat: add YouTrack API client and invoice generator with documentation
This commit is contained in:
221
.factory/skills/youtrack/scripts/invoice_generator.py
Normal file
221
.factory/skills/youtrack/scripts/invoice_generator.py
Normal 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()
|
||||
249
.factory/skills/youtrack/scripts/youtrack_api.py
Normal file
249
.factory/skills/youtrack/scripts/youtrack_api.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTrack REST API Client
|
||||
Handles authentication and basic API calls for YouTrack Cloud instances.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from urllib.parse import urljoin
|
||||
from typing import Optional, Dict, List, Any
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class YouTrackAPI:
|
||||
"""Simple YouTrack REST API client."""
|
||||
|
||||
def __init__(self, base_url: str, token: Optional[str] = None):
|
||||
"""
|
||||
Initialize YouTrack API client.
|
||||
|
||||
Args:
|
||||
base_url: Your YouTrack instance URL (e.g., https://sl.youtrack.cloud)
|
||||
token: Permanent API token (or set YOUTRACK_TOKEN env var)
|
||||
"""
|
||||
# Normalize base URL
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.token = token or os.environ.get('YOUTRACK_TOKEN')
|
||||
|
||||
if not self.token:
|
||||
raise ValueError(
|
||||
"YouTrack token required. Set YOUTRACK_TOKEN env var or pass as argument."
|
||||
)
|
||||
|
||||
# Set up headers with bearer token auth
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Make an authenticated API request.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint (e.g., '/api/issues')
|
||||
data: Request body for POST/PUT
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
"""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
|
||||
req_data = None
|
||||
if data is not None:
|
||||
req_data = json.dumps(data).encode('utf-8')
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=req_data,
|
||||
headers=self.headers,
|
||||
method=method
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
if response.status >= 400:
|
||||
error_body = response.read().decode('utf-8')
|
||||
raise RuntimeError(f"API Error {response.status}: {error_body}")
|
||||
|
||||
result = response.read().decode('utf-8')
|
||||
if result:
|
||||
return json.loads(result)
|
||||
return {}
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode('utf-8') if e.fp else ''
|
||||
raise RuntimeError(f"HTTP Error {e.code}: {error_body}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Request failed: {e}")
|
||||
|
||||
# Projects
|
||||
|
||||
def get_projects(self) -> List[Dict]:
|
||||
"""Get all projects."""
|
||||
result = self._make_request('GET', '/api/admin/projects?fields=id,name,shortName')
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def get_project(self, project_id: str) -> Dict:
|
||||
"""Get a specific project by ID."""
|
||||
return self._make_request('GET', f'/api/admin/projects/{project_id}?fields=id,name,shortName,description')
|
||||
|
||||
# Issues
|
||||
|
||||
def get_issues(self, query: Optional[str] = None, fields: str = 'id,summary,description,created,updated,project(id,name),customFields(name,value)') -> List[Dict]:
|
||||
"""
|
||||
Get issues, optionally filtered by a query.
|
||||
|
||||
Args:
|
||||
query: YouTrack query language (e.g., 'project: MyProject')
|
||||
fields: Comma-separated list of fields to return
|
||||
|
||||
Returns:
|
||||
List of issues
|
||||
"""
|
||||
params = {'fields': fields}
|
||||
if query:
|
||||
params['query'] = query
|
||||
|
||||
# Build query string
|
||||
query_string = '&'.join(f'{k}={urllib.parse.quote(str(v))}' for k, v in params.items())
|
||||
endpoint = f'/api/issues?{query_string}'
|
||||
|
||||
result = self._make_request('GET', endpoint)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def get_issue(self, issue_id: str) -> Dict:
|
||||
"""Get a specific issue by ID."""
|
||||
return self._make_request('GET', f'/api/issues/{issue_id}')
|
||||
|
||||
def create_issue(self, project_id: str, summary: str, description: str = '') -> Dict:
|
||||
"""
|
||||
Create a new issue.
|
||||
|
||||
Args:
|
||||
project_id: Project ID or short name
|
||||
summary: Issue summary
|
||||
description: Issue description
|
||||
|
||||
Returns:
|
||||
Created issue
|
||||
"""
|
||||
data = {
|
||||
'project': {'id': project_id},
|
||||
'summary': summary,
|
||||
'description': description
|
||||
}
|
||||
return self._make_request('POST', '/api/issues', data)
|
||||
|
||||
def update_issue(self, issue_id: str, summary: Optional[str] = None, description: Optional[str] = None) -> Dict:
|
||||
"""Update an issue's summary and/or description."""
|
||||
data = {}
|
||||
if summary is not None:
|
||||
data['summary'] = summary
|
||||
if description is not None:
|
||||
data['description'] = description
|
||||
return self._make_request('POST', f'/api/issues/{issue_id}', data)
|
||||
|
||||
# Time Tracking
|
||||
|
||||
def get_work_items(self, issue_id: str) -> List[Dict]:
|
||||
"""Get all work items (time entries) for an issue."""
|
||||
result = self._make_request('GET', f'/api/issues/{issue_id}/timeTracking/workItems?fields=id,date,duration(minutes),author(name),text')
|
||||
# Convert date from milliseconds to ISO format
|
||||
for wi in result:
|
||||
if 'date' in wi and wi['date']:
|
||||
wi['date'] = datetime.fromtimestamp(wi['date'] / 1000).isoformat()
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def get_issue_with_work_items(self, issue_id: str) -> Dict:
|
||||
"""Get an issue with all its work items included."""
|
||||
issue = self.get_issue(issue_id)
|
||||
work_items = self.get_work_items(issue_id)
|
||||
issue['workItems'] = work_items
|
||||
return issue
|
||||
|
||||
# Knowledge Base (Articles)
|
||||
|
||||
def get_articles(self, project_id: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Get knowledge base articles.
|
||||
|
||||
Args:
|
||||
project_id: Optional project ID to filter by
|
||||
|
||||
Returns:
|
||||
List of articles
|
||||
"""
|
||||
endpoint = '/api/articles'
|
||||
if project_id:
|
||||
endpoint += f'?project={project_id}'
|
||||
|
||||
result = self._make_request('GET', endpoint)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def get_article(self, article_id: str) -> Dict:
|
||||
"""Get a specific article by ID."""
|
||||
return self._make_request('GET', f'/api/articles/{article_id}')
|
||||
|
||||
def create_article(self, project_id: str, title: str, content: str) -> Dict:
|
||||
"""
|
||||
Create a new knowledge base article.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
title: Article title
|
||||
content: Article content
|
||||
|
||||
Returns:
|
||||
Created article
|
||||
"""
|
||||
data = {
|
||||
'project': {'id': project_id},
|
||||
'title': title,
|
||||
'content': content
|
||||
}
|
||||
return self._make_request('POST', '/api/articles', data)
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI interface for testing the YouTrack API."""
|
||||
parser = argparse.ArgumentParser(description='YouTrack API Client')
|
||||
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('--list-projects', action='store_true', help='List all projects')
|
||||
parser.add_argument('--list-issues', help='List issues (optional query)')
|
||||
parser.add_argument('--get-issue', help='Get specific issue ID')
|
||||
parser.add_argument('--get-articles', action='store_true', help='List articles')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
api = YouTrackAPI(args.url, args.token)
|
||||
|
||||
if args.list_projects:
|
||||
projects = api.get_projects()
|
||||
print(json.dumps(projects, indent=2))
|
||||
elif args.list_issues is not None:
|
||||
issues = api.get_issues(query=args.list_issues)
|
||||
print(json.dumps(issues, indent=2))
|
||||
elif args.get_issue:
|
||||
issue = api.get_issue_with_work_items(args.get_issue)
|
||||
print(json.dumps(issue, indent=2))
|
||||
elif args.get_articles:
|
||||
articles = api.get_articles()
|
||||
print(json.dumps(articles, indent=2))
|
||||
else:
|
||||
print("No action specified. Use --help for options.")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user