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.
353 lines
14 KiB
Python
353 lines
14 KiB
Python
#!/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()))
|