skills-marketplace/plugins/timesheet/skills/invoice-prep/scripts/run.py
Michał Kopeć 576a14c82f Add ivona and timesheet plugins, MIT LICENSE, and invoice-prep-test
Adds ivona (invoice-checker, timesheet-checker) and timesheet
(check-my-timesheet, invoice-prep) as new plugins, plus invoice-prep-test — a
one-off experiment that requests the Tempo API token via plugin userConfig at
install time instead of a .env file, to check whether that value actually
reaches the skill's script. Also adds the root LICENSE (MIT) referenced from
the README.
2026-07-15 15:00:21 +02:00

353 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Generate invoice line items from the user's own Tempo worklogs for a given month.
Groups hours by Jira project code and reports missing time vs the Polish
working calendar.
"""
from __future__ import annotations
import argparse
import base64
import calendar
import json
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
from pathlib import Path
from typing import Any
# ── .env loading ──────────────────────────────────────────────────────────────
def load_dotenv(*paths: Path) -> Path | None:
for path in paths:
if not path.exists():
continue
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
return path
return None
# ── Period helpers ────────────────────────────────────────────────────────────
def parse_month(month_str: str) -> tuple[date, date]:
"""Parse 'YYYY-MM' into (first_day, last_day) of that month."""
s = month_str.strip()
if not re.fullmatch(r"\d{4}-\d{2}", s):
raise ValueError(f"Invalid month '{month_str}'. Use YYYY-MM.")
year, month = int(s[:4]), int(s[5:7])
first = date(year, month, 1)
last = date(year, month, calendar.monthrange(year, month)[1])
return first, last
# ── Public holidays ───────────────────────────────────────────────────────────
def get_public_holidays(country: str, year: int) -> set[date]:
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
req = urllib.request.Request(url, headers={"User-Agent": "invoice-prep/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return {date.fromisoformat(item["date"]) for item in json.loads(resp.read())}
except Exception as exc:
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
return set()
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
result, cur = [], start
while cur <= end:
if cur.weekday() < 5 and cur not in holidays:
result.append(cur)
cur += timedelta(days=1)
return result
# ── Jira account lookup ───────────────────────────────────────────────────────
def resolve_account_id(email: str, jira_base_url: str,
jira_email: str, jira_token: str) -> str:
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
users = json.loads(resp.read())
except urllib.error.HTTPError as exc:
if exc.code == 401:
print("Error: Jira API returned 401. Check JIRA_EMAIL and JIRA_API_TOKEN.",
file=sys.stderr)
sys.exit(1)
raise RuntimeError(f"Jira API {exc.code}") from exc
match = next(
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
None,
)
if not match:
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
sys.exit(1)
return match["accountId"]
# ── Tempo worklogs ────────────────────────────────────────────────────────────
_PROJECT_CODE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})-\d+\b")
def load_jira_ignore(path: Path) -> set[str]:
"""Load issue keys to ignore from a markdown table in jira_ignore.md."""
if not path.exists():
return set()
ignored: set[str] = set()
in_table = False
header_seen = False
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not (line.startswith("|") and line.endswith("|")):
in_table = False
header_seen = False
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if not header_seen:
# First row is header or separator
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
continue # separator row
header_seen = True
in_table = True
continue
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
continue # separator row
if in_table and cells and cells[0]:
ignored.add(cells[0].strip().upper())
return ignored
def extract_project_code(issue_key: str) -> str | None:
"""Extract project code from a Jira issue key, e.g. 'IAA' from 'IAA-42'."""
m = _PROJECT_CODE_RE.match(issue_key.strip())
return m.group(1) if m else None
def fetch_worklogs(account_id: str, start: date, end: date,
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
"""Return all Tempo worklog entries for the account in [start, end]."""
results: list[dict] = []
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
next_url: str | None = (
f"{base_url}/worklogs/user/{account_id}"
f"?from={start}&to={end}&limit=1000"
)
while next_url:
req = urllib.request.Request(next_url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if exc.code == 401:
print("Error: Tempo API returned 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
sys.exit(1)
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
results.extend(data.get("results", []))
next_url = data.get("metadata", {}).get("next")
return results
def resolve_issue_keys(worklogs: list[dict], jira_email: str,
jira_token: str) -> dict[int, str]:
"""
Resolve Jira issue IDs → keys by calling each issue's self URL directly.
Uses the self URL already present in each Tempo worklog entry.
"""
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
seen: dict[int, str] = {}
for wl in worklogs:
issue = wl.get("issue", {})
issue_id = issue.get("id")
if not issue_id or int(issue_id) in seen:
continue
self_url = issue.get("self", "")
if not self_url:
continue
# Swap api/2 → api/3 if needed; append fields filter
url = re.sub(r"/rest/api/\d+/", "/rest/api/3/", self_url) + "?fields=key"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
seen[int(issue_id)] = data.get("key", "")
except Exception as exc:
print(f"Warning: could not resolve issue {issue_id}: {exc}", file=sys.stderr)
return seen
# ── Main ──────────────────────────────────────────────────────────────────────
def run(args: argparse.Namespace) -> int:
skill_dir = Path(__file__).parent.parent
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
if loaded:
print(f"Loaded credentials from {loaded}")
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
jira_email = os.environ.get("JIRA_EMAIL", "")
jira_token = os.environ.get("JIRA_API_TOKEN", "")
missing = [n for n, v in [
("TEMPO_API_TOKEN", tempo_token), ("JIRA_BASE_URL", jira_base_url),
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token),
] if not v]
if missing:
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
return 1
try:
start, end = parse_month(args.month)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
# Load ignore list
ignore_path = Path.cwd() / "jira_ignore.md"
ignored_keys = load_jira_ignore(ignore_path)
if ignored_keys:
print(f"Ignoring issues: {', '.join(sorted(ignored_keys))}")
print(f"Month: {args.month} ({start} {end})")
print(f"User: {jira_email}")
# Resolve account ID
print("Resolving Jira account...")
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
print(f" {jira_email}{account_id}")
# Working days (Polish calendar)
print("Fetching public holidays...")
holidays = get_public_holidays("PL", start.year)
wdays = working_days(start, end, holidays)
expected_hours = len(wdays) * 8.0
print(f" Working days: {len(wdays)} Expected hours: {expected_hours:.0f}h")
# Fetch Tempo worklogs
print("Fetching Tempo worklogs...")
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
print(f" Worklogs fetched: {len(worklogs)}")
# Resolve issue IDs → keys via each issue's self URL (Tempo v4 omits the key)
unique_ids = {int(wl["issue"]["id"]) for wl in worklogs if wl.get("issue", {}).get("id")}
issue_key_map: dict[int, str] = {}
if unique_ids:
print(f" Resolving {len(unique_ids)} unique issue key(s) from Jira...")
issue_key_map = resolve_issue_keys(worklogs, jira_email, jira_token)
print(f" Resolved: {len(issue_key_map)}")
# Group hours by project code and by date, skipping ignored issues
project_hours: dict[str, float] = {}
hours_by_date: dict[str, float] = {}
unmatched_hours = 0.0
ignored_hours = 0.0
for wl in worklogs:
issue_id = int(wl.get("issue", {}).get("id", 0))
issue_key = issue_key_map.get(issue_id, "")
hours = wl.get("timeSpentSeconds", 0) / 3600
wl_date = wl.get("startDate", "")
if issue_key.upper() in ignored_keys:
ignored_hours += hours
# Still count toward date totals so missing-day detection stays accurate
if wl_date:
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
continue
code = extract_project_code(issue_key)
if code:
project_hours[code] = project_hours.get(code, 0.0) + hours
else:
unmatched_hours += hours
if wl_date:
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
# Sort projects by hours descending
projects = sorted(
[{"code": k, "hours": round(v, 2)} for k, v in project_hours.items()],
key=lambda x: x["hours"],
reverse=True,
)
total_logged = round(sum(project_hours.values()) + unmatched_hours + ignored_hours, 2)
total_billable = round(sum(project_hours.values()) + unmatched_hours, 2)
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
# Find working days with no logged time
days_missing = [
d.isoformat() for d in wdays
if hours_by_date.get(d.isoformat(), 0.0) == 0.0
]
output = {
"month": args.month,
"user_email": jira_email,
"account_id": account_id,
"working_days": len(wdays),
"expected_hours": expected_hours,
"total_logged_hours": total_logged,
"total_billable_hours": total_billable,
"ignored_hours": round(ignored_hours, 2),
"ignored_keys": sorted(ignored_keys),
"missing_hours": missing_hours,
"unmatched_hours": round(unmatched_hours, 2),
"days_with_no_hours": days_missing,
"projects": projects,
}
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
out_path = output_dir / f"invoice_prep_{args.month}.json"
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote {out_path}")
# Print quick summary
print(f"\nProjects:")
for p in projects:
print(f" {p['code']:12s} {p['hours']:.2f}h")
if unmatched_hours:
print(f" (no project) {unmatched_hours:.2f}h")
if ignored_hours:
print(f" (ignored) {ignored_hours:.2f}h ({', '.join(sorted(ignored_keys))})")
print(f"\nBillable total: {total_billable:.2f}h")
print(f"Total logged: {total_logged:.2f}h / {expected_hours:.0f}h expected")
if missing_hours > 0:
print(f"⚠ Missing: {missing_hours:.2f}h ({len(days_missing)} day(s) with no time logged)")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate invoice line items from Tempo worklogs."
)
parser.add_argument("--month", required=True, help="Month to check: YYYY-MM")
parser.add_argument("--output-dir", required=True,
help="Directory for invoice_prep_YYYY-MM.json")
return parser
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))