skills-marketplace/plugins/timesheet/skills/check-my-timesheet/scripts/check.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

250 lines
10 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
"""Check the current user's timesheet for gaps against the 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
# ── Config loading ────────────────────────────────────────────────────────────
def load_config() -> dict[str, Any]:
"""Read timesheet config from env vars MY_TIMESHEET_COUNTRY and MY_TIMESHEET_HOURS_PER_DAY."""
country = os.environ.get("MY_TIMESHEET_COUNTRY", "PL").strip().upper()
raw_hpd = os.environ.get("MY_TIMESHEET_HOURS_PER_DAY", "8").strip()
try:
hours_per_day = float(raw_hpd)
except ValueError:
print(f"Warning: invalid MY_TIMESHEET_HOURS_PER_DAY '{raw_hpd}' — using 8.", file=sys.stderr)
hours_per_day = 8.0
return {"country": country, "hours_per_day": hours_per_day}
# ── Period parsing ────────────────────────────────────────────────────────────
def parse_period(period_str: str) -> tuple[date, date]:
s = period_str.strip()
if ":" in s:
a, b = s.split(":", 1)
return date.fromisoformat(a.strip()), date.fromisoformat(b.strip())
raise ValueError(f"Invalid period '{period_str}'. Use YYYY-MM-DD:YYYY-MM-DD.")
# ── Public holidays ───────────────────────────────────────────────────────────
def get_holidays(country: str, years: set[int]) -> set[date]:
holidays: set[date] = set()
for year in sorted(years):
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
req = urllib.request.Request(url, headers={"User-Agent": "check-my-timesheet/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
holidays |= {date.fromisoformat(i["date"]) for i in json.loads(r.read())}
except Exception as exc:
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
return holidays
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 r:
users = json.loads(r.read())
except urllib.error.HTTPError as exc:
if exc.code == 401:
print("Error: Jira 401. Check JIRA_EMAIL and JIRA_API_TOKEN.", file=sys.stderr)
sys.exit(1)
raise
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 ────────────────────────────────────────────────────────────
def fetch_worklogs(account_id: str, start: date, end: date,
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
results: list[dict] = []
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
next_url: str | None = (
f"{base_url}/worklogs/user/{account_id}?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 r:
data = json.loads(r.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if exc.code == 401:
print("Error: Tempo 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
# ── 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
cfg = load_config()
print(f"Config: country={cfg['country']}, hours_per_day={cfg['hours_per_day']}")
try:
start, end = parse_period(args.period)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(f"Period: {start} {end}")
print(f"User: {jira_email}")
print("Resolving Jira account...")
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
print(f" {jira_email}{account_id}")
print("Fetching public holidays...")
holidays = get_holidays(cfg["country"], {start.year, end.year})
wdays = working_days(start, end, holidays)
expected_hours = len(wdays) * cfg["hours_per_day"]
print(f" Working days: {len(wdays)} Expected: {expected_hours:.0f}h")
print("Fetching Tempo worklogs...")
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
print(f" Worklogs: {len(worklogs)}")
# Aggregate hours per date
hours_by_date: dict[str, float] = {}
for wl in worklogs:
d = wl.get("startDate", "")
if d:
hours_by_date[d] = hours_by_date.get(d, 0.0) + wl.get("timeSpentSeconds", 0) / 3600
# Classify each working day
hpd = cfg["hours_per_day"]
days_out = []
for d in wdays:
logged = round(hours_by_date.get(d.isoformat(), 0.0), 2)
if logged >= hpd:
status = "ok"
entry = {"date": d.isoformat(), "logged_hours": logged, "status": "ok"}
elif logged > 0:
missing_h = round(hpd - logged, 2)
status = "partial"
entry = {"date": d.isoformat(), "logged_hours": logged,
"status": "partial", "missing": missing_h}
else:
status = "missing"
entry = {"date": d.isoformat(), "logged_hours": 0.0,
"status": "missing", "missing": hpd}
days_out.append(entry)
total_logged = round(sum(hours_by_date.get(d.isoformat(), 0.0) for d in wdays), 2)
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
output = {
"period": {"start": str(start), "end": str(end)},
"config": cfg,
"working_days": len(wdays),
"expected_hours": expected_hours,
"total_logged_hours": total_logged,
"missing_hours": missing_hours,
"days": days_out,
}
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
out_path = output_dir / f"my_check_{start}_{end}.json"
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote {out_path}")
# Quick summary
problem_days = [d for d in days_out if d["status"] != "ok"]
print(f"\nLogged: {total_logged:.1f}h / {expected_hours:.0f}h expected")
if not problem_days:
print("✓ Timesheet complete.")
else:
missing_days = [d for d in problem_days if d["status"] == "missing"]
partial_days = [d for d in problem_days if d["status"] == "partial"]
if missing_days:
print(f"✗ Missing ({len(missing_days)} day(s)): "
+ ", ".join(d["date"] for d in missing_days))
if partial_days:
print(f"⚠ Partial ({len(partial_days)} day(s)): "
+ ", ".join(f"{d['date']} ({d['logged_hours']}h)" for d in partial_days))
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Check timesheet gaps for the current user.")
p.add_argument("--period", required=True, help="YYYY-MM-DD:YYYY-MM-DD")
p.add_argument("--output-dir", required=True)
return p
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))