Merge branch 'add-timesheet-invoice-skills'

This commit is contained in:
Michał Kopeć 2026-07-15 13:31:37 +02:00
commit e62c0cf24c
21 changed files with 4231 additions and 0 deletions

View file

@ -0,0 +1,149 @@
---
name: check-my-timesheet
description: >
Two behaviours in one skill:
(1) CHECK — show the current user's timesheet gaps for a given period. Compares
Tempo worklogs against the working calendar and flags missing or partial days.
(2) LOG — help the user log time to Tempo. User provides a description or issue key;
Claude searches Jira if needed, confirms the entry, then posts it.
TRIGGER check when user says: "check my timesheet", "timesheet gaps", "where am I missing
hours", "did I log all hours", "show my missing time".
TRIGGER log when user says: "log time", "add time entry", "log hours", "report hours",
"book time on", "add worklog".
---
# Check My Timesheet
Personal timesheet checker and time logger for the user identified by `JIRA_EMAIL`.
## Prerequisites
All configuration lives in the `.env` file in the project root:
| Variable | Purpose | Default |
|---|---|---|
| `TEMPO_API_TOKEN` | Read and write Tempo worklogs | — |
| `JIRA_BASE_URL` | Resolve account ID and search issues | — |
| `JIRA_EMAIL` | Identifies whose timesheet to read / who is logging | — |
| `JIRA_API_TOKEN` | Authenticate with Jira | — |
| `MY_TIMESHEET_COUNTRY` | Country code for public holiday calendar (`PL`, `GB`, `DE`…) | `PL` |
| `MY_TIMESHEET_HOURS_PER_DAY` | Expected working hours per day | `8` |
## Setup
Determine `SKILL_DIR` from the location of this file.
---
## Behaviour 1 — Check timesheet gaps
### Trigger phrases
"check my timesheet", "timesheet gaps", "where am I missing hours", "did I log all hours",
"show my time", "show my missing time"
### Workflow
**Step 1 — Ask for period.** Always ask — never assume a default:
> "Which period should I check? (e.g. this week, last week, June 2026, or a date range)"
Convert natural language to `YYYY-MM-DD:YYYY-MM-DD`:
- "this week" → Monday of current week to yesterday
- "last week" → previous MondayFriday
- "June 2026" → `2026-06-01:2026-06-30`
- "today" → today:today
**Step 2 — Run check:**
```bash
python3 {SKILL_DIR}/scripts/check.py \
--period {YYYY-MM-DD:YYYY-MM-DD} \
--output-dir ./timesheet-output
```
**Step 3 — Present results.** Read the output JSON and show:
1. Summary: `Logged X h / Y h expected (Z working days)`
2. If `missing_hours == 0`: "✓ Timesheet complete for this period."
3. Otherwise, a table of all working days — highlight missing and partial:
```
| Date | Logged | Status |
| 2026-06-23 | 8.0 h | ✓ OK |
| 2026-06-24 | 4.0 h | ⚠ Partial (4h missing) |
| 2026-06-25 | 0.0 h | ✗ Missing |
```
4. List only the problem days prominently if there are many OK days.
---
## Behaviour 2 — Log time
### Trigger phrases
"log time", "add time entry", "log hours", "report hours", "book time on", "add worklog",
"log X hours on", "log X hours for"
### Workflow
**Step 1 — Collect what is needed:**
From the user's message extract or ask for:
- **Issue** — Jira issue key (e.g. `IAA-42`) or a description to search by
- **Hours** — number of hours (decimals OK: 1.5 = 1h30m)
- **Date** — which day to log on (default: today if not mentioned)
- **Description** — optional note for the worklog
If issue key is not provided but a description is, run a search:
```bash
python3 {SKILL_DIR}/scripts/log.py \
--mode search \
--query "{description}"
```
Present the results (up to 5 issues) and ask the user to pick one or confirm.
If no results found, ask the user to provide the issue key directly.
**Step 2 — Preview (run without --confirmed).** Always run the preview step first:
```bash
python3 {SKILL_DIR}/scripts/log.py \
--mode log \
--issue {KEY} \
--hours {N} \
--date {YYYY-MM-DD} \
--description "{description}"
```
The script prints the pending worklog and exits without writing anything.
If hours exceed `MY_TIMESHEET_HOURS_PER_DAY` it also prints a `⚠ WARNING` line.
**Step 3 — Show preview to user and ask for confirmation.** Present the full script output
including any warnings. Wait for explicit yes/no. Do NOT proceed without explicit confirmation.
**Step 4 — Log (only after yes, with --confirmed):**
```bash
python3 {SKILL_DIR}/scripts/log.py \
--mode log \
--issue {KEY} \
--hours {N} \
--date {YYYY-MM-DD} \
--description "{description}" \
--confirmed
```
**Step 5 — Report result.** Show the worklog ID and a confirmation message.
Offer to check the timesheet again to verify the entry is reflected.
---
## Guardrails
- Never log time without explicit user confirmation.
- Always run the preview step first — the `--confirmed` flag must only be added after the user says yes.
- If the preview shows a `⚠ WARNING` (hours exceed daily limit), make sure the user acknowledges it before confirming.
- If `my_timesheet.md` is missing, stop and show the user the template.
- If `JIRA_EMAIL` is not set, stop and ask the user to set it in `.env`.
- If any script step fails, show the error and stop.
- Do not guess the issue key — always search or ask if unsure.

View file

@ -0,0 +1,250 @@
#!/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()))

View file

@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Search Jira issues or log a time entry to Tempo.
Modes:
--mode search --query "text"
Search Jira for matching issues. Returns JSON list of matches.
--mode log --issue KEY --hours N --date YYYY-MM-DD [--description "..."]
Post a worklog to Tempo. Requires confirmation from the caller.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
# ── .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
# ── Jira helpers ──────────────────────────────────────────────────────────────
def jira_headers(jira_email: str, jira_token: str) -> dict:
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
return {"Authorization": f"Basic {creds}", "Accept": "application/json",
"Content-Type": "application/json"}
def resolve_account_id(email: str, jira_base_url: str,
jira_email: str, jira_token: str) -> str:
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
req = urllib.request.Request(url, headers=jira_headers(jira_email, jira_token))
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"]
def search_issues(query: str, jira_base_url: str,
jira_email: str, jira_token: str) -> list[dict]:
"""Search Jira for issues matching the query. Uses POST /search/jql (v3)."""
body = json.dumps({
"jql": f'text ~ "{query}" ORDER BY updated DESC',
"fields": ["key", "summary", "status", "assignee"],
"maxResults": 5,
}).encode()
url = f"{jira_base_url.rstrip('/')}/rest/api/3/search/jql"
req = urllib.request.Request(url, data=body,
headers=jira_headers(jira_email, jira_token),
method="POST")
try:
with urllib.request.urlopen(req, timeout=15) as r:
data = json.loads(r.read())
except urllib.error.HTTPError as exc:
body_txt = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Jira search failed ({exc.code}): {body_txt[:300]}") from exc
results = []
for issue in data.get("issues", []):
fields = issue.get("fields", {})
results.append({
"key": issue["key"],
"summary": fields.get("summary", ""),
"status": fields.get("status", {}).get("name", ""),
"assignee": (fields.get("assignee") or {}).get("displayName", "unassigned"),
})
return results
def get_issue(issue_key: str, jira_base_url: str,
jira_email: str, jira_token: str) -> dict:
"""Fetch id and summary of a Jira issue. Returns {"id": int, "summary": str}."""
url = f"{jira_base_url.rstrip('/')}/rest/api/3/issue/{issue_key}?fields=summary,id"
req = urllib.request.Request(url, headers=jira_headers(jira_email, jira_token))
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
return {
"id": int(data.get("id", 0)),
"summary": data.get("fields", {}).get("summary", ""),
}
except Exception:
return {"id": 0, "summary": ""}
# ── Tempo log ─────────────────────────────────────────────────────────────────
def log_worklog(account_id: str, issue_id: int, hours: float,
log_date: str, description: str, tempo_token: str,
base_url: str = "https://api.tempo.io/4") -> dict:
"""POST a new worklog to Tempo. Returns the created worklog data."""
body = {
"issueId": issue_id,
"timeSpentSeconds": int(hours * 3600),
"startDate": log_date,
"startTime": "09:00:00",
"authorAccountId": account_id,
}
if description:
body["description"] = description
data = json.dumps(body).encode()
headers = {
"Authorization": f"Bearer {tempo_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
req = urllib.request.Request(f"{base_url}/worklogs", data=data,
headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
except urllib.error.HTTPError as exc:
body_txt = 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_txt[:300]}") from exc
# ── 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 [("JIRA_BASE_URL", jira_base_url),
("JIRA_EMAIL", jira_email),
("JIRA_API_TOKEN", jira_token)] if not v]
if args.mode == "log" and not tempo_token:
missing.append("TEMPO_API_TOKEN")
if missing:
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
return 1
# ── Search mode ───────────────────────────────────────────────────────────
if args.mode == "search":
if not args.query:
print("Error: --query is required for search mode.", file=sys.stderr)
return 1
print(f"Searching Jira for: {args.query!r}")
try:
results = search_issues(args.query, jira_base_url, jira_email, jira_token)
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(json.dumps(results, indent=2, ensure_ascii=False))
if not results:
print("No issues found.", file=sys.stderr)
return 0
# ── Log mode ──────────────────────────────────────────────────────────────
if not args.issue:
print("Error: --issue is required for log mode.", file=sys.stderr)
return 1
if not args.hours or args.hours <= 0:
print("Error: --hours must be a positive number.", file=sys.stderr)
return 1
if not args.date:
print("Error: --date is required for log mode (YYYY-MM-DD).", file=sys.stderr)
return 1
issue_key = args.issue.strip().upper()
if not re.fullmatch(r"[A-Z][A-Z0-9]+-\d+", issue_key):
print(f"Error: '{issue_key}' is not a valid Jira issue key (expected e.g. IAA-42).",
file=sys.stderr)
return 1
# Read configured daily limit for over-hours warning
try:
hours_per_day = float(os.environ.get("MY_TIMESHEET_HOURS_PER_DAY", "8"))
except ValueError:
hours_per_day = 8.0
print(f"Resolving Jira account for {jira_email}...")
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
# Fetch issue ID and summary (Tempo v4 requires numeric issueId, not issueKey)
issue_info = get_issue(issue_key, jira_base_url, jira_email, jira_token)
issue_id = issue_info["id"]
summary = issue_info["summary"]
if not issue_id:
print(f"Error: could not resolve numeric ID for {issue_key}.", file=sys.stderr)
return 1
# ── Guardrail: over-hours warning ─────────────────────────────────────────
if args.hours > hours_per_day:
print(
f"\n⚠ WARNING: {args.hours}h exceeds the configured daily limit "
f"of {hours_per_day}h (MY_TIMESHEET_HOURS_PER_DAY).",
file=sys.stderr,
)
# ── Preview (always shown) ────────────────────────────────────────────────
print(f"\nPending worklog:")
print(f" Issue: {issue_key}" + (f"{summary}" if summary else ""))
print(f" Hours: {args.hours}h")
print(f" Date: {args.date}")
if args.description:
print(f" Note: {args.description}")
# ── Guardrail: require explicit confirmation ───────────────────────────────
if not args.confirmed:
print(
"\n⏸ Not logged. Re-run with --confirmed to post this worklog to Tempo."
)
return 0
try:
result = log_worklog(
account_id=account_id,
issue_id=issue_id,
hours=args.hours,
log_date=args.date,
description=args.description or "",
tempo_token=tempo_token,
)
worklog_id = result.get("tempoWorklogId") or result.get("id", "?")
print(f"\n✓ Worklog logged. Tempo ID: {worklog_id}")
return 0
except RuntimeError as exc:
print(f"\nError: {exc}", file=sys.stderr)
return 1
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Search Jira or log time to Tempo.")
p.add_argument("--mode", required=True, choices=["search", "log"])
p.add_argument("--query", help="Search text (search mode)")
p.add_argument("--issue", help="Jira issue key, e.g. IAA-42 (log mode)")
p.add_argument("--hours", type=float, help="Hours to log, e.g. 2.5 (log mode)")
p.add_argument("--date", help="Date YYYY-MM-DD (log mode)")
p.add_argument("--description", default="", help="Optional worklog note (log mode)")
p.add_argument("--confirmed", action="store_true",
help="Must be passed to actually post the worklog. Without it the script "
"only shows a preview.")
return p
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))

View file

@ -0,0 +1,13 @@
# Invoice Checker — credentials template
# Copy this file to .env (in the project root) and fill in the values.
# .env is gitignored and never committed.
# Your company NIP — 10 digits, no dashes or spaces
KSEF_NIP=1111111111
# KSeF authorization token — generate in the KSeF portal under Zarządzanie tokenami
# Grant it at minimum: "Odczyt faktur" (invoice read) permissions.
KSEF_TOKEN=20260601-EC-25684C5000-A33DFDC343-5F|nip-1111111111|4b6eaeb9027d45428fe714ff2a32a8f75bae7b2b83c14fb1bd64a60543767df5
# Environment: "test" (sandbox) or "prod" (production)
KSEF_ENV=test

View file

@ -0,0 +1 @@
.env

View file

@ -0,0 +1,116 @@
---
name: invoice-checker
description: >
Pull and check invoices from KSeF (Krajowy System e-Faktur) for a selected period.
For every new invoice checks whether the contractor is in the contractors file and
whether the contractor's bank account is on the MF white list (Biała lista podatników VAT).
TRIGGER this skill automatically whenever the user mentions pulling, checking, reviewing,
or auditing invoices from KSeF — especially when they mention a period like last week,
last month, or a date range. Trigger phrases include: "check invoices", "pull invoices",
"invoice audit", "KSeF invoices", "check KSeF", "white list check", "biała lista".
---
# Invoice Checker
Use this skill when someone needs to download purchase invoices from KSeF for a given period, verify contractors, and check bank accounts against the MF white list.
**In Claude Code, you run the full pipeline automatically.** The user provides a period — you handle everything else.
## Prerequisites
This skill reads credentials from the `.env` file in the **project root** (the working directory where Claude Code is open). The same file is shared with the timesheet-checker skill — just add the KSEF variables to it.
Add these lines to your `.env`:
```env
# KSeF — Invoice Checker
KSEF_NIP= # 10-digit company NIP, no dashes
KSEF_TOKEN= # Authorization token from KSeF portal → Zarządzanie tokenami
KSEF_ENV=prod # "test" for sandbox, "prod" for production
```
The variable reference:
| Variable | What it is | Where to get it |
|---|---|---|
| `KSEF_NIP` | Company NIP (10 digits, no dashes) | Your company's tax ID |
| `KSEF_TOKEN` | KSeF authorization token | KSeF portal → Zarządzanie tokenami |
| `KSEF_ENV` | `test` or `prod` | Use `test` for the sandbox, `prod` for production |
The script also requires the `cryptography` Python package:
```bash
pip3 install cryptography
```
## Setup
Determine `SKILL_DIR` from the location of this file. Set the output directory to `./invoices-output/` in the current working directory. Create it if it does not exist.
## Load First
Read these reference files:
1. The contractors config — check `./contractors.md` in the current working directory first; if not present, fall back to `{SKILL_DIR}/references/contractors.md`. Tell the user which one is being used.
2. `{SKILL_DIR}/references/output-format.md` — the structure of `invoices_data.json` and the final report.
## Extracting the period
The user may express the period in various ways. Convert to `YYYY-MM-DD:YYYY-MM-DD` before running:
| User says | Start | End |
|---|---|---|
| "last week" | Last Monday | Last Friday |
| "last month" | 1st of previous month | Last day of previous month |
| "this month" | 1st of current month | Yesterday |
| "this year" | 1 January of current year | Yesterday |
| "June", "June 2026" | 2026-06-01 | 2026-06-30 |
| Explicit dates | As given | As given |
If the user has not specified a period, ask: "Który okres sprawdzić? (np. zeszły tydzień, zeszły miesiąc, zakres dat)"
## Workflow
**Step 1 — Fetch and check.** Run this command:
```bash
python3 {SKILL_DIR}/scripts/preprocess.py \
--period {YYYY-MM-DD:YYYY-MM-DD} \
--contractors {contractors_path} \
--output-dir ./invoices-output
```
This authenticates with KSeF, downloads all purchase invoices for the period, compares against `invoices_cache.json` to identify new invoices, checks each contractor against `contractors.md`, checks bank accounts against the MF white list, and writes `invoices_data.json` to `./invoices-output/`.
**Step 2 — Interpret.** Read `./invoices-output/invoices_data.json`. Note:
- **New vs known invoices** — only new ones (not in cache) are actively checked.
- **Unknown contractors** — contractors not in `contractors.md`. Show these to the user and ask if they should be added.
- **White list failures** — bank accounts not found on the white list. These are high priority — flag them prominently.
- **White list errors** — API call failed (network issue, etc.). Note that these could not be verified.
**Step 3 — Generate report.**
```bash
python3 {SKILL_DIR}/scripts/postprocess.py \
--data ./invoices-output/invoices_data.json \
--output-dir ./invoices-output
```
This writes `invoices_report.md`.
**Step 4 — Present results.** Show the user:
- Summary block: total invoices, new invoices, contractor status, white list status.
- Table of new invoices with contractor and white list status.
- Any white list failures or unknown contractors — highlighted prominently.
- "Full report saved to `./invoices-output/invoices_report.md`."
**Step 5 — Handle unknown contractors.** If the user confirms any unknown contractors should be added, add them to `contractors.md`. Use the NIP and name from the invoice; leave `bank_accounts` blank if they want to fill it in later, or populate it from the invoice bank account.
## Guardrails
- Never fabricate invoice data. All data comes from KSeF.
- White list checks are always performed against the invoice issue date (not today).
- If `KSEF_NIP` or `KSEF_TOKEN` are not set, stop and ask the user for them.
- If the `cryptography` package is missing, tell the user to run `pip3 install cryptography` and stop.
- If any script step fails, show the error and stop. Do not proceed with partial output.
- Always terminate the KSeF session even if an error occurs (the script handles this internally).

View file

@ -0,0 +1,15 @@
# Contractors
Add a row for each contractor. Use their NIP as the primary identifier — the script matches invoices by NIP.
`bank_accounts` is a semicolon-separated list of IBAN numbers (with or without the PL prefix, spaces ignored).
Leave it blank if you do not want to maintain expected accounts — the white list check will still run against the account from the invoice.
`max_invoice_net_value` — maximum net total (PLN) allowed for a single invoice. Leave blank for no limit.
`max_position_net_rate` — maximum unit price (net) allowed on any single line item. Leave blank for no limit.
`max_total_quantity` — maximum sum of quantities across all line items on a single invoice. Leave blank for no limit.
`email` — contact email address for the contractor (used for notifications).
`check_with_jira` — set to `yes` (or `true` / `1`) to indicate invoices from this contractor should be cross-checked with Jira.
| name | nip | bank_accounts | max_invoice_net_value | max_position_net_rate | max_total_quantity | email | check_with_jira | notes |
| -----| ---| --------------| ----------------------| ----------------------| -------------------| ------| ----------------| ------|

View file

@ -0,0 +1,70 @@
# Output Format
## invoices_data.json (written by preprocess.py)
```json
{
"period": {
"start": "2026-06-01",
"end": "2026-06-30"
},
"summary": {
"total_invoices": 12,
"new_invoices": 4,
"known_contractors": 3,
"unknown_contractors": 1,
"whitelist_ok": 3,
"whitelist_failed": 1,
"whitelist_error": 0,
"account_mismatch": 0
},
"invoices": [
{
"ksef_reference": "1234567890-20260601-ABC123",
"invoice_number": "FV/123/06/2026",
"issue_date": "2026-06-01",
"seller_nip": "1234567890",
"seller_name": "Firma ABC Sp. z o.o.",
"gross_amount": 1230.00,
"currency": "PLN",
"bank_account": "PL61109010140000071219812874",
"is_new": true,
"contractor_known": true,
"account_in_contractors": true,
"whitelist_status": "ok",
"whitelist_account_assigned": true,
"whitelist_error": null
}
],
"known_invoices_count": 8
}
```
### Field notes
- `is_new` — true if the KSeF reference was not in `invoices_cache.json` before this run. The cache is updated at the end of a successful run.
- `contractor_known` — true if the seller NIP is in `contractors.md`.
- `account_in_contractors` — true if the invoice bank account matches one of the accounts listed in `contractors.md` for this NIP. `null` if the contractor is unknown or has no accounts configured.
- `whitelist_status``"ok"` (account found on white list), `"failed"` (account NOT on white list), `"no_account"` (invoice has no bank account), `"error"` (API call failed).
- `whitelist_account_assigned` — the raw `accountAssigned` value from the MF API (`true`/`false`). `null` if not checked.
- `whitelist_error` — error message string if the API call failed, otherwise `null`.
## invoices_report.md (written by postprocess.py)
Manager-readable markdown report containing:
- Period and run timestamp
- Summary block
- Table of new invoices with contractor and white list status
- Section highlighting white list failures (if any)
- Section listing unknown contractors (if any)
### Status icons used in the report
| Icon | Meaning |
|---|---|
| ✓ | White list OK |
| ✗ | White list FAILED — do not pay until resolved |
| ? | Could not verify (API error) |
| — | No bank account on invoice |
| ★ | Known contractor |
| ✦ | Unknown contractor |

View file

@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""Format invoice check data into a markdown report."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def fmt_amount(amount: Any, currency: str = "PLN") -> str:
if amount is None:
return ""
try:
return f"{float(amount):,.2f} {currency}"
except (TypeError, ValueError):
return str(amount)
def contractor_badge(inv: dict) -> str:
if inv.get("contractor_known"):
mismatch = inv.get("account_in_contractors") is False
return "" + (" ⚠acc" if mismatch else "")
return "✦ NEW"
def whitelist_badge(inv: dict) -> str:
status = inv.get("whitelist_status", "")
if status == "ok":
return ""
if status == "failed":
return "✗ FAILED"
if status == "no_account":
return ""
if status == "error":
return "? err"
if status == "no_nip":
return "? no NIP"
return status
def rules_badge(inv: dict) -> str:
violations = inv.get("rule_violations") or []
return f"{len(violations)}" if violations else ""
def duplicate_badge(inv: dict) -> str:
if inv.get("potential_duplicate"):
return "⚠ DUPE?"
if inv.get("second_in_month"):
return "2nd"
return ""
def jira_badge(inv: dict) -> str:
if not inv.get("jira_checked"):
return ""
if inv.get("jira_compliant"):
return ""
return ""
def _invoice_table_row(inv: dict) -> str:
return (
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '')} "
f"| {inv.get('issue_date') or ''} "
f"| {inv.get('seller_name') or ''} "
f"| {inv.get('seller_nip') or ''} "
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} "
f"| {contractor_badge(inv)} "
f"| {whitelist_badge(inv)} "
f"| {rules_badge(inv)} "
f"| {duplicate_badge(inv)} "
f"| {jira_badge(inv)} |"
)
def _invoice_table_header() -> list[str]:
return [
"| Invoice | Date | Seller | NIP | Amount | Contractor | White list | Rules | Dup. | Jira |",
"| ---| ---| ---| ---| ---:| ---| ---| ---| ---| ---|",
]
def _legend() -> list[str]:
return [
"",
"---",
"",
"**Legend:** ★ = known contractor · ✦ NEW = unknown contractor · "
"✓ = white list OK · ✗ FAILED = not on white list · "
"— = no bank account · ? = could not verify · ⚠acc = account not in contractors.md · "
"⚠ N = N rule violation(s) · ✓ = rules OK · "
"2nd = 2nd invoice from seller this month · ⚠ DUPE? = same amount as earlier invoice · "
"✓/✗ Jira = Jira compliance check result",
"",
"_White list source: [wl-api.mf.gov.pl](https://wl-api.mf.gov.pl)_",
"_Invoice source: KSeF (Ministerstwo Finansów)_",
]
def build_report(data: dict[str, Any]) -> str:
"""Build the per-run report covering only new invoices."""
period = data["period"]
summary = data["summary"]
all_invoices = data.get("invoices", [])
new_invoices = [i for i in all_invoices if i.get("is_new")]
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
env = data.get("ksef_env", "prod")
lines = [
"# Invoice Check Report",
"",
f"Generated: {generated_at}",
f"Period: {period['start']} to {period['end']}",
f"KSeF environment: {env}",
"",
"## Summary",
"",
f"- Total invoices in period: **{summary['total_invoices']}**",
f"- New (checked this run): **{summary['new_invoices']}**",
f"- Known contractors: {summary['known_contractors']}",
f"- Unknown contractors: **{summary['unknown_contractors']}**",
f"- White list OK: {summary['whitelist_ok']}",
f"- White list FAILED: **{summary['whitelist_failed']}**",
f"- White list errors: {summary['whitelist_error']}",
f"- Account mismatch (not in contractors.md): {summary['account_mismatch']}",
f"- Rule violations (limit exceeded): **{summary.get('rule_violations', 0)}**",
f"- 2nd invoice from same seller this month: **{summary.get('second_invoices', 0)}**",
f"- Potential duplicates (same seller, same month, same amount): **{summary.get('potential_duplicates', 0)}**",
f"- Jira non-compliant: **{summary.get('jira_non_compliant', 0)}**",
]
# Jira non-compliance — prominent
jira_bad = [i for i in new_invoices if i.get("jira_checked") and not i.get("jira_compliant")]
if jira_bad:
lines += [
"",
"## ✗ Jira Non-Compliant Invoices",
"",
"> Quantities on these invoices do not match hours logged in Jira/Tempo.",
"",
]
for inv in jira_bad:
lines += [
f"**{inv.get('invoice_number') or inv.get('ksef_reference', '')}** "
f"{inv.get('seller_name') or ''} ({inv.get('issue_date') or ''})",
"",
]
violations = inv.get("jira_violations") or []
no_proj = inv.get("jira_no_project_positions") or []
if violations:
lines += [
"| Project | Invoice qty | Jira hours | Month |",
"| ---| ---:| ---:| ---|",
]
for v in violations:
lines.append(
f"| {v['project']} | {v['invoice_qty']} | {v['jira_hours']} | {v['month']} |"
)
lines.append("")
if no_proj:
lines.append(
"Positions without a Jira project code: "
+ ", ".join(
f"#{p['line_number']} ({p['description'] or 'no description'})"
for p in no_proj
)
)
lines.append("")
# Potential duplicates — prominent
dupes = [i for i in new_invoices if i.get("potential_duplicate")]
if dupes:
lines += [
"",
"## ⚠ Potential Duplicates",
"",
"> These invoices have the same seller and gross amount as another invoice in the same month.",
"> Verify they are not duplicate payments before approving.",
"",
"| Invoice | Date | Seller | NIP | Amount |",
"| ---| ---| ---| ---| ---:|",
]
for inv in dupes:
lines.append(
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '')} "
f"| {inv.get('issue_date') or ''} "
f"| {inv.get('seller_name') or ''} "
f"| {inv.get('seller_nip') or ''} "
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
)
# Rule violations — prominent
rule_violated = [i for i in new_invoices if i.get("rule_violations")]
if rule_violated:
lines += [
"",
"## ⚠ Rule Violations",
"",
"> These invoices exceed limits configured in `contractors.md`.",
"",
"| Invoice | Seller | Rule | Limit | Actual | Position |",
"| ---| ---| ---| ---:| ---:| ---|",
]
for inv in rule_violated:
for v in inv.get("rule_violations", []):
rule_label = {
"max_invoice_net_value": "Max invoice net",
"max_position_net_rate": "Max position rate",
"max_total_quantity": "Max total qty",
}.get(v["rule"], v["rule"])
pos_info = f"#{v['position']} {v.get('description', '')}" if v.get("position") else ""
lines.append(
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '')} "
f"| {inv.get('seller_name') or ''} "
f"| {rule_label} "
f"| {v['limit']:,.2f} "
f"| {v['actual']:,.2f} "
f"| {pos_info} |"
)
# White list failures — prominent
failed = [i for i in new_invoices if i.get("whitelist_status") == "failed"]
if failed:
lines += [
"",
"## ✗ White List Failures — DO NOT PAY",
"",
"> These bank accounts are **not registered** on the MF white list for the given NIP.",
"> Verify manually before making any payment.",
"",
"| Invoice | Seller | NIP | Bank account | Amount |",
"| ---| ---| ---| ---| ---:|",
]
for inv in failed:
lines.append(
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
f"| {inv.get('seller_name') or ''} "
f"| {inv.get('seller_nip') or ''} "
f"| {inv.get('bank_account') or ''} "
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
)
# New invoices table
if new_invoices:
lines += ["", "## New Invoices", ""] + _invoice_table_header()
for inv in new_invoices:
lines.append(_invoice_table_row(inv))
else:
lines += ["", "## New Invoices", "", "_No new invoices found for this period._"]
# Unknown contractors
unknown = [i for i in new_invoices if not i.get("contractor_known")]
if unknown:
lines += [
"",
"## ✦ Unknown Contractors",
"",
"These sellers are not in `contractors.md`. Consider adding them.",
"",
"| Seller name | NIP | Bank account |",
"| ---| ---| ---|",
]
seen_nips: set[str] = set()
for inv in unknown:
nip = inv.get("seller_nip") or ""
if nip in seen_nips:
continue
seen_nips.add(nip)
lines.append(
f"| {inv.get('seller_name') or ''} | {nip} | {inv.get('bank_account') or ''} |"
)
# Account mismatches
mismatches = [i for i in new_invoices if i.get("account_in_contractors") is False]
if mismatches:
lines += [
"",
"## ⚠ Account Mismatches",
"",
"These invoices carry a bank account **not listed** in `contractors.md` for the seller.",
"",
"| Invoice | Seller | NIP | Invoice account |",
"| ---| ---| ---| ---|",
]
for inv in mismatches:
lines.append(
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
f"| {inv.get('seller_name') or ''} "
f"| {inv.get('seller_nip') or ''} "
f"| {inv.get('bank_account') or ''} |"
)
# White list errors
errors = [i for i in new_invoices if i.get("whitelist_status") == "error"]
if errors:
lines += [
"",
"## ? White List Check Errors",
"",
"| Invoice | Seller | NIP | Error |",
"| ---| ---| ---| ---|",
]
for inv in errors:
lines.append(
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
f"| {inv.get('seller_name') or ''} "
f"| {inv.get('seller_nip') or ''} "
f"| {inv.get('whitelist_error') or ''} |"
)
lines += _legend()
return "\n".join(lines) + "\n"
def build_all_report(all_data: dict[str, Any]) -> str:
"""Build the cumulative report covering all invoices ever processed."""
updated_at = all_data.get("updated_at", "")[:19].replace("T", " ") + " UTC"
invoices_map: dict[str, dict] = all_data.get("invoices", {})
# Sort by issue_date descending, then by ksef_reference for stability
invoices = sorted(
invoices_map.values(),
key=lambda i: (i.get("issue_date") or "0000-00-00", i.get("ksef_reference", "")),
reverse=True,
)
total = len(invoices)
wl_ok = sum(1 for i in invoices if i.get("whitelist_status") == "ok")
wl_failed = sum(1 for i in invoices if i.get("whitelist_status") == "failed")
unknown_count = sum(1 for i in invoices if not i.get("contractor_known"))
gross_total = sum(float(i["gross_amount"]) for i in invoices if i.get("gross_amount") is not None)
lines = [
"# All Invoices",
"",
f"Last updated: {updated_at}",
f"Total: **{total}** invoice(s)",
"",
"## Summary",
"",
f"- Total invoices: **{total}**",
f"- Total gross value: **{gross_total:,.2f} PLN**",
f"- White list OK: {wl_ok}",
f"- White list FAILED: **{wl_failed}**",
f"- Unknown contractors: **{unknown_count}**",
]
# White list failures — prominent
failed = [i for i in invoices if i.get("whitelist_status") == "failed"]
if failed:
lines += [
"",
"## ✗ White List Failures — DO NOT PAY",
"",
"> These bank accounts are **not registered** on the MF white list.",
"",
"| Invoice | Seller | NIP | Bank account | Amount |",
"| ---| ---| ---| ---| ---:|",
]
for inv in failed:
lines.append(
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '')} "
f"| {inv.get('seller_name') or ''} "
f"| {inv.get('seller_nip') or ''} "
f"| {inv.get('bank_account') or ''} "
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
)
# Full invoices table
lines += ["", "## All Invoices (newest first)", ""] + _invoice_table_header()
for inv in invoices:
lines.append(_invoice_table_row(inv))
lines += _legend()
return "\n".join(lines) + "\n"
def run(args: argparse.Namespace) -> int:
data_path = Path(args.data)
if not data_path.exists():
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
return 1
data = json.loads(data_path.read_text(encoding="utf-8"))
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Per-run report (new invoices only)
report_path = output_dir / "invoices_report.md"
report_path.write_text(build_report(data), encoding="utf-8")
print(f"Wrote {report_path}")
# Cumulative report (all invoices ever processed)
all_data_path = output_dir / "invoices_all.json"
if all_data_path.exists():
all_data = json.loads(all_data_path.read_text(encoding="utf-8"))
all_report_path = output_dir / "invoices_all_report.md"
all_report_path.write_text(build_all_report(all_data), encoding="utf-8")
print(f"Wrote {all_report_path}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Format invoice check data into markdown reports."
)
parser.add_argument("--data", required=True,
help="Path to invoices_data.json produced by preprocess.py.")
parser.add_argument("--output-dir", required=True,
help="Directory for invoices_report.md and invoices_all_report.md.")
return parser
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
---
name: invoice-prep
description: >
Show what to put on an invoice for a selected month by checking the user's
own Tempo timesheets. Lists every Jira project code and the total hours logged
against it. Warns if there are missing hours or days with no logged time.
TRIGGER when user asks what to put on an invoice, what to invoice, invoice
preparation, timesheet summary for invoicing, or similar phrases.
Trigger phrases: "what should I put on an invoice", "prepare my invoice",
"invoice for [month]", "what to invoice for", "check my TS for invoice".
---
# Invoice Preparation
Use this skill when someone wants to know what project codes and hours to put on their invoice for a given month. It reads their own Tempo worklogs, groups hours by Jira project, and warns about any missing time.
**In Claude Code, you run the pipeline automatically.** The user provides a month — you handle everything else.
## Prerequisites
Uses the same credentials as the timesheet-checker — no extra setup needed:
| Variable | Purpose |
|---|---|
| `TEMPO_API_TOKEN` | Fetch worklogs from Tempo |
| `JIRA_BASE_URL` | Resolve the user's account ID |
| `JIRA_EMAIL` | Identifies whose timesheets to read |
| `JIRA_API_TOKEN` | Authenticate with Jira |
All four must be present in the `.env` file in the project root.
## Extracting the month
Convert natural language to `YYYY-MM` before running:
| User says | CLI value |
|---|---|
| "last month" | Previous calendar month |
| "this month" | Current calendar month |
| "June", "June 2026" | `2026-06` |
| "May" | `2026-05` (current year) |
If no month is specified, ask: "Which month should I check? (e.g. last month, June 2026)"
## Workflow
**Step 1 — Run the script:**
```bash
python3 {SKILL_DIR}/scripts/run.py \
--month {YYYY-MM} \
--output-dir ./timesheet-output
```
This resolves the user's Jira account, fetches their Tempo worklogs for the full calendar month, groups hours by Jira project code, checks completeness against the Polish working calendar, and writes `invoice_prep_{YYYY-MM}.json` to `./timesheet-output/`.
**Step 2 — Present results.** Read the output JSON and show the user:
1. A table of project codes and hours — formatted ready to copy onto an invoice.
Include **every project in the `projects` array**. Do NOT label any project as "non-billable" or omit it from the table — the script already excludes ignored issues; everything remaining is billable:
```
| Project | Hours |
| IAA | 45.5 |
| PROJ | 22.0 |
```
Below the table, note ignored issues and their hours as a parenthetical: `(INTERNAL-1: 40h ignored per config.)` — do not mix them into the billable table.
2. The **total billable hours** from `total_billable_hours` (not `total_logged_hours`).
3. If `missing_hours > 0`: a clear warning showing:
- How many hours are missing vs the expected total for the month
- Which specific dates have no logged time (up to 10; if more, show count)
- Reminder to complete the timesheet before issuing the invoice
4. If `total_logged_hours == 0`: warn that no hours were found at all for the month.
## Guardrails
- Never fabricate or estimate hours. All data comes from Tempo.
- If `JIRA_EMAIL` is not set, stop and tell the user.
- If any script step fails, show the error and stop.
- Always remind the user that incomplete timesheets will cause Jira compliance failures when the invoice is checked.

View file

@ -0,0 +1,353 @@
#!/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()))

View file

@ -0,0 +1,15 @@
# Timesheet Checker — credentials template
# Copy this file to .env and fill in your values.
# .env is gitignored and never committed.
# Tempo API token — generate at: Tempo → Settings → API Integration
TEMPO_API_TOKEN=
# Your Atlassian / Jira Cloud URL
JIRA_BASE_URL=https://your-company.atlassian.net
# The email address you use to log into Jira
JIRA_EMAIL=your.email@company.com
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
JIRA_API_TOKEN=

View file

@ -0,0 +1 @@
.env

View file

@ -0,0 +1,209 @@
# Timesheet Checker
Checks whether employees have completed their timesheets for a given period by pulling Tempo worklogs. Compares against each employee's country calendar (public holidays fetched from date.nager.at) and a configurable minimum of hours per day. Produces a completion report ranked by missing hours.
---
## Prerequisites
The skill needs four credentials. The easiest setup is a `.env` file — copy the template and fill it in once:
macOS / Linux:
```bash
cp .claude/skills/timesheet-checker/.env.example .claude/skills/timesheet-checker/.env
```
Windows (PowerShell):
```powershell
Copy-Item .claude\skills\timesheet-checker\.env.example .claude\skills\timesheet-checker\.env
```
Or copy the file manually in Explorer / Finder — rename `.env.example` to `.env` in the skill folder.
Then open `.env` and fill in your values:
```
TEMPO_API_TOKEN=your_tempo_token
JIRA_BASE_URL=https://your-company.atlassian.net
JIRA_EMAIL=your.email@company.com
JIRA_API_TOKEN=your_jira_token
```
The script looks for `.env` in the **working directory first**, then the skill folder as a fallback. This means you can also place a `.env` in your project root if you prefer one file for all skills.
Environment variables set via `export` always take priority over `.env` values, so both approaches work side-by-side.
The `.env` file is gitignored — credentials are never committed.
| Variable | What it is | Where to get it |
|---|---|---|
| `TEMPO_API_TOKEN` | Tempo read token | Tempo → Settings → API Integration |
| `JIRA_BASE_URL` | Your Atlassian URL | The URL you use to open Jira |
| `JIRA_EMAIL` | Your Jira login email | Your Atlassian account email |
| `JIRA_API_TOKEN` | Jira personal API token | [id.atlassian.com](https://id.atlassian.com) → Security → API tokens |
If any credential is missing from both `.env` and the environment, the script stops with a clear error listing exactly what is needed.
---
## How to use in Claude Code
Claude Code runs as a CLI, desktop app, web app (claude.ai/code), or IDE extension. **Claude Code runs the full pipeline automatically — no manual script steps.**
**Install** — copy the skill folder into your project's Claude skills directory:
macOS / Linux:
```bash
cp -r src/timesheet-checker .claude/skills/
```
Windows (PowerShell):
```powershell
Copy-Item -Recurse -Path src\timesheet-checker -Destination .claude\skills\
```
**Use:**
Just describe what you want — Claude will pick up the skill automatically:
```
Check if timesheets are complete for last week
```
```
Timesheet completion for last month
```
```
Check logged hours for 2026-06-01 to 2026-06-30
```
Claude fetches Polish public holidays, pulls Tempo worklogs, computes completion per employee, generates the report, and shows you the results. Output files land in `./timesheet-output/`.
---
## How to use in the Claude.ai app
> Full automation (no manual script running) requires Claude Code. In the Claude.ai app, the Python steps must be run locally.
**Set up once (Project approach):**
1. Create a new Project in Claude.ai.
2. In **Project Instructions**, paste the full contents of `SKILL.md`.
3. Upload `references/employees.md` and `references/output-format.md` to the Project knowledge.
**Each time you run:**
1. Set all four credentials in your shell, then run the pre-processor locally:
```bash
python3 $SKILL_DIR/scripts/preprocess.py \
--period last-week \
--output-dir ./timesheet-output
```
The script will use `./employees.md` if it exists, otherwise `$SKILL_DIR/references/employees.md`.
2. Upload `timesheet_data.json` to the Project conversation.
3. Ask Claude to summarise and present the results.
4. Run the post-processor locally to generate the formatted report:
```bash
python3 $SKILL_DIR/scripts/postprocess.py \
--data ./timesheet-output/timesheet_data.json \
--output-dir ./timesheet-output
```
**Without Projects:** Paste the contents of `SKILL.md` at the start of a new conversation, attach the two reference files, then follow the same steps.
---
## What is included
| File | Description |
|---|---|
| `SKILL.md` | Orchestrating skill — instructions for Claude |
| `references/employees.md` | Editable list of employees and their Tempo account IDs |
| `references/output-format.md` | Structure of `timesheet_data.json` and the final report |
| `scripts/preprocess.py` | Fetches Tempo worklogs + Polish holidays, computes completion |
| `scripts/postprocess.py` | Formats `timesheet_data.json` into the markdown report |
| `sample/employees_sample.md` | Sample employees file with placeholder account IDs |
## Outputs (in `./timesheet-output/`)
| File | Description |
|---|---|
| `timesheet_data.json` | Computed stats per employee + period + summary |
| `timesheet_report.md` | Manager-readable report ranked by missing hours |
---
## Configuring employees
The script looks for the employees file in this order:
1. `employees.md` in the **current working directory** — place it here for a project-specific list
2. `references/employees.md` in the **skill folder** — the default shipped with the skill
To use a project-specific list, copy the sample and edit it:
```bash
cp .claude/skills/timesheet-checker/sample/employees_sample.md ./employees.md
```
The file has two tables:
**Defaults** — applies to every employee unless overridden:
| setting | value |
|---|---|
| `country` | ISO 3166-1 alpha-2 country code (e.g. `PL`, `GB`, `DE`). Used for public holiday lookup. |
| `expected_hours_per_day` | Minimum hours per working day |
**Employees** — one row per person. Leave any column blank to inherit the default.
| Column | What it controls |
|---|---|
| `name` | Display name in the report |
| `email` | Work email address (used to look up the Jira account ID automatically) |
| `country` | Override the default country for this employee |
| `expected_hours_per_day` | Override the default hours for this employee |
| `start_date` | First working day — leave blank if employed for the full period |
---
## Period formats
| Input | Meaning | End date |
|---|---|---|
| `last-week` | Previous MondayFriday | Last Friday |
| `last-month` | Previous calendar month | Last day of that month |
| `last-year` | Previous calendar year | 31 December |
| `yesterday` | Yesterday only | Yesterday |
| `current-week` | Monday of this week to yesterday | Yesterday |
| `current-month` | 1st of this month to yesterday | Yesterday |
| `current-year` | 1st January of this year to yesterday | Yesterday |
| `YYYY-MM-DD:YYYY-MM-DD` | Explicit date range | As specified |
For `current-*` periods the current in-progress day is always excluded — end date is yesterday. If today is the first day of the period (e.g. `current-week` on a Monday), the period contains no past working days and the report will show 0 expected hours.
In Claude Code, you can also use natural language ("last week", "this month", "yesterday", "June 2026") and Claude converts it automatically.
---
## How working days are calculated
The script fetches Polish public holidays from the [date.nager.at](https://date.nager.at) public API (no authentication needed). It then excludes weekends and those holidays from the period. This runs live each time — holiday data is never hardcoded.
---
## Integration
This skill connects to **Tempo Cloud** (`api.tempo.io/4`) by default. For self-hosted Tempo (Jira Data Center), pass the correct base URL:
```bash
python3 $SKILL_DIR/scripts/preprocess.py \
--tempo-base-url https://your-jira.company.com/rest/tempo-timesheets/4 \
...
```
Or in Claude Code:
```
Check timesheets for last week, Tempo is at https://jira.company.com/rest/tempo-timesheets/4
```
---
## Limits
- Requires `TEMPO_API_TOKEN` — the script stops immediately with a clear error if it is missing.
- Employees with 0 hours may be on leave; the tool does not distinguish between missing logs and approved absence. Review these manually.
- If the period end date is today or in the future, the period is still open and data will be partial.
- Polish holidays only. If your team uses a different country calendar, the `get_polish_holidays` function in `preprocess.py` needs updating.
- Tempo Cloud API v4 only. Tempo Server / Data Center may require a different base URL and authentication method.

View file

@ -0,0 +1,115 @@
---
name: timesheet-checker
description: >
Check timesheet completion for a given period by pulling Tempo worklogs for all reporters
found in Tempo (not just those in employees.md). Flags anyone missing from employees.md
and asks the user to add them. Requires TEMPO_API_TOKEN. Checks against the Polish working
calendar and 8h/day minimum. Produces a completion report ranked by missing hours.
TRIGGER this skill automatically whenever the user mentions checking, reviewing, or auditing
timesheets or timesheet completion — especially when they mention a period like last week,
last month, or a date range. Trigger phrases include: "check timesheets", "timesheet completion",
"review timesheets", "who hasn't logged hours", "timesheet audit", "check logged hours",
"hours reported", or any message asking about timesheet status or missing hours.
---
# Timesheet Checker
Use this skill when someone needs to know if employees have completed their timesheets for a given period. It pulls worklogs from Tempo, computes each person's completion against the Polish working calendar, and ranks employees by missing hours.
**In Claude Code, you run the full pipeline automatically.** The user provides a period — you handle everything else.
## Prerequisites
This skill requires four credentials. The easiest way to provide them is a `.env` file — copy `.env.example` in the skill folder and fill in the values:
```
cp {SKILL_DIR}/.env.example {SKILL_DIR}/.env
# then edit {SKILL_DIR}/.env
```
Or place the `.env` file in the working directory (where you run Claude Code) — that takes priority over the skill folder.
If the user prefers environment variables they can still use `export`, but the `.env` file removes the need to do that every session.
The four credentials:
| Variable | What it is | Where to get it |
|---|---|---|
| `TEMPO_API_TOKEN` | Tempo read token | Tempo → Settings → API Integration |
| `JIRA_BASE_URL` | Your Atlassian URL | The URL you use to open Jira |
| `JIRA_EMAIL` | Your Jira login email | Your Atlassian account email |
| `JIRA_API_TOKEN` | Jira personal API token | [id.atlassian.com](https://id.atlassian.com) → Security → API tokens |
`JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` are used to resolve employee email addresses to Jira account IDs before querying Tempo. `TEMPO_API_TOKEN` is used to pull the actual worklogs.
## Setup
Determine `SKILL_DIR` from the location of this file. Set the output directory to `./timesheet-output/` in the current working directory. Create it if it does not exist.
## Load First
Read these reference files:
1. The employees config — check `./employees.md` in the current working directory first; if not present, fall back to `{SKILL_DIR}/references/employees.md`. Tell the user which one is being used.
2. `{SKILL_DIR}/references/output-format.md` — the structure of `timesheet_data.json` and the final report.
## Extracting the period
The user may express the period in various ways. Convert to the CLI format before running:
| User says | CLI value | End date |
|---|---|---|
| "last week" | `last-week` | Last Friday |
| "last month" | `last-month` | Last day of previous month |
| "last year" | `last-year` | 31 December of previous year |
| "yesterday" | `yesterday` | Yesterday |
| "this week", "current week" | `current-week` | Yesterday |
| "this month", "current month" | `current-month` | Yesterday |
| "this year", "current year" | `current-year` | Yesterday |
| "June", "June 2026" | `2026-06-01:2026-06-30` | Explicit |
| Explicit dates | `YYYY-MM-DD:YYYY-MM-DD` | Explicit |
For `current-*` periods the end date is always yesterday — the current in-progress day is never included. If a user asks for "current week" on a Monday (the week only started today), there are no past working days yet; note this to the user.
If the user has not specified a period, ask: "Which period should I check? (e.g. last week, this month, yesterday, or a date range)"
## Workflow
**Step 1 - Fetch and compute.** Run this command with bash:
```bash
python3 {SKILL_DIR}/scripts/preprocess.py \
--period {period} \
--output-dir ./timesheet-output
```
This fetches Polish public holidays from date.nager.at, computes working days, pulls all Tempo worklogs for the period, and writes `timesheet_data.json` to `./timesheet-output/`.
**Step 2 - Interpret.** Read `./timesheet-output/timesheet_data.json`. Note:
- Any employee with `reported_hours: 0` — this may mean vacation, sick leave, or forgotten logging. Mention these explicitly.
- If the period end date is today or in the future, the period is still open — note this so the user knows the data is partial.
- The overall completion percentage and how many employees have gaps.
- **Unknown reporters:** If `unknown_reporters` is non-empty, show the user a table of those people (name, email, hours logged) and ask them to add the missing employees to `employees.md`. Then continue with Steps 34 using only the known employees, noting that the report excludes the unknown reporters until they are added.
**Step 3 - Generate report.** Run this command with bash:
```bash
python3 {SKILL_DIR}/scripts/postprocess.py \
--data ./timesheet-output/timesheet_data.json \
--output-dir ./timesheet-output
```
This writes `timesheet_report.md`.
**Step 4 - Present results.** Show the user:
- The summary block (expected / reported / overall completion %).
- The full employee table from the report.
- Any notable observations from Step 2.
- "Full report saved to `./timesheet-output/timesheet_report.md`."
## Guardrails
- Never invent or estimate hours. All data comes from Tempo.
- Working days are computed from the Polish public holidays API — do not hardcode holiday dates.
- If `TEMPO_API_TOKEN` is not set, stop and ask the user for it. Do not proceed without it.
- If any script step fails, show the error and stop. Do not proceed with partial output.

View file

@ -0,0 +1,22 @@
# Employees
## Defaults
These values apply to every employee unless overridden in the Employees table.
| setting | value |
| --- | --- |
| country | PL |
| expected_hours_per_day | 8 |
## Employees
Add a row for each employee. Use their work email address — the script looks up the Jira account ID automatically. Leave any column blank to inherit the default.
`country` is an ISO 3166-1 alpha-2 code (e.g. `PL`, `GB`, `DE`, `FR`). Public holidays for that country are fetched from date.nager.at. `start_date` is the employee's first working day — leave blank if they were employed for the full period.
| name | email | country | expected_hours_per_day | start_date |
| --- | --- | --- | --- | --- |
| Jan Kowalski | jan.kowalski@company.com | | | |
| Anna Nowak | anna.nowak@company.com | | | |
| Piotr Wiśniewski | piotr.wisniewski@company.com | | | 2026-05-15 |

View file

@ -0,0 +1,49 @@
# Output Format
## timesheet_data.json (written by preprocess.py)
Machine-readable intermediate file with all fetched and computed data.
```json
{
"period": {
"start": "2026-06-16",
"end": "2026-06-20",
"label": "last week",
"working_days": ["2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19", "2026-06-20"],
"working_day_count": 5
},
"summary": {
"total_employees": 3,
"total_expected_hours": 120,
"total_reported_hours": 96.5,
"overall_completion_pct": 80.4,
"employees_fully_complete": 1,
"employees_with_gaps": 2
},
"employees": [
{
"name": "Jan Kowalski",
"account_id": "557058:aaaa...",
"expected_hours": 40,
"reported_hours": 32.5,
"missing_hours": 7.5,
"completion_pct": 81.3
}
]
}
```
Field notes:
- `employees` is sorted by `missing_hours` descending — the employee furthest behind appears first.
- `completion_pct` = `reported_hours / expected_hours × 100`. Can exceed 100 if overtime was logged.
- `missing_hours` = `max(0, expected_hours - reported_hours)`.
- Working days exclude weekends and Polish public holidays fetched from date.nager.at.
## timesheet_report.md (written by postprocess.py)
Manager-readable markdown report containing:
- Period details (dates, working day count)
- Summary block: expected total, reported total, overall completion %
- Employee table ranked by missing hours (most behind first)
- Completion badges: ✓ complete, ⚠ below 75%, ⚠⚠ below 50%

View file

@ -0,0 +1,20 @@
# Employees (sample)
## Defaults
| setting | value |
| --- | --- |
| country | PL |
| expected_hours_per_day | 8 |
## Employees
Replace email addresses with your team's real work emails.
| name | email | country | expected_hours_per_day | start_date |
| --- | --- | --- | --- | --- |
| Jan Kowalski | jan.kowalski@company.com | | | |
| Anna Nowak | anna.nowak@company.com | | | |
| Piotr Wiśniewski | piotr.wisniewski@company.com | | | 2026-05-15 |
| Marta Jabłońska | marta.jablonska@company.com | | | |
| Tom Smith | tom.smith@company.com | GB | | |

View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Format timesheet completion data into a markdown report."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def fmt_h(hours: float) -> str:
"""Format hours with one decimal place, dropping .0 for whole numbers."""
if hours == int(hours):
return f"{int(hours)}h"
return f"{hours:.1f}h"
def completion_badge(pct: float) -> str:
if pct >= 100:
return ""
if pct >= 75:
return ""
if pct >= 50:
return ""
return "⚠⚠"
def build_report(data: dict[str, Any]) -> str:
period = data["period"]
summary = data["summary"]
employees = data["employees"]
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
n = summary["total_employees"]
per_person = fmt_h(summary["total_expected_hours"] / n) if n else "0h"
wdays_by_country: dict[str, int] = period.get("working_days_by_country", {})
if len(wdays_by_country) == 1:
country, wdays = next(iter(wdays_by_country.items()))
wdays_line = f"Working days: {wdays} ({country} calendar, public holidays excluded)"
else:
parts = ", ".join(f"{c}: {d}d" for c, d in sorted(wdays_by_country.items()))
wdays_line = f"Working days by country: {parts} (public holidays excluded)"
lines = [
"# Timesheet Completion Report",
"",
f"Generated: {generated_at}",
f"Period: {period['label'].capitalize()} ({period['start']} to {period['end']})",
wdays_line,
"",
"## Summary",
"",
f"- Expected: **{fmt_h(summary['total_expected_hours'])}** ({n} employees × {per_person} avg)",
f"- Reported: **{fmt_h(summary['total_reported_hours'])}**",
f"- Overall completion: **{summary['overall_completion_pct']}%**",
f"- Fully complete: {summary['employees_fully_complete']} of {n} employees",
f"- With gaps: {summary['employees_with_gaps']} employees",
"",
"## Employees (ranked by missing hours)",
"",
"| Employee | Expected | Reported | Completion | Missing |",
"| --- | ---: | ---: | ---: | ---: |",
]
# Determine the most common working-day count to detect deviations
max_wdays = max(wdays_by_country.values()) if wdays_by_country else 0
for emp in employees:
badge = completion_badge(emp["completion_pct"])
missing = fmt_h(emp["missing_hours"]) if emp["missing_hours"] > 0 else ""
pct_str = f"{emp['completion_pct']}% {badge}".strip()
name = emp["name"]
notes = []
eff = emp.get("effective_working_days", max_wdays)
if emp.get("start_date") and eff < max_wdays:
notes.append(f"from {emp['start_date']}")
if emp.get("termination_date") and eff < max_wdays:
notes.append(f"until {emp['termination_date']}")
if len(wdays_by_country) > 1:
notes.append(emp.get("country", ""))
if notes:
name += f" _({', '.join(n for n in notes if n)})_"
lines.append(
f"| {name} | {fmt_h(emp['expected_hours'])} "
f"| {fmt_h(emp['reported_hours'])} | {pct_str} | {missing} |"
)
out_of_range = [
emp for emp in employees
if emp.get("hours_before_start", 0) > 0 or emp.get("hours_after_termination", 0) > 0
]
if out_of_range:
lines += [
"",
"## Hours logged outside employment period",
"",
"These hours are excluded from the completion calculation above.",
"",
"| Employee | Before start date | After termination date |",
"| --- | ---: | ---: |",
]
for emp in out_of_range:
before = fmt_h(emp["hours_before_start"]) if emp.get("hours_before_start", 0) > 0 else ""
after = fmt_h(emp["hours_after_termination"]) if emp.get("hours_after_termination", 0) > 0 else ""
lines.append(f"| {emp['name']} | {before} | {after} |")
countries_str = ", ".join(sorted(wdays_by_country.keys()))
lines += [
"",
"---",
"",
f"_Working days source: [date.nager.at](https://date.nager.at) ({countries_str})._",
"_Hours source: Tempo._",
]
return "\n".join(lines) + "\n"
def run(args: argparse.Namespace) -> int:
data_path = Path(args.data)
if not data_path.exists():
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
return 1
data = json.loads(data_path.read_text(encoding="utf-8"))
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
report = build_report(data)
report_path = output_dir / "timesheet_report.md"
report_path.write_text(report, encoding="utf-8")
print(f"Wrote {report_path}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Format timesheet completion data into a markdown report."
)
parser.add_argument(
"--data",
required=True,
help="Path to timesheet_data.json produced by preprocess.py.",
)
parser.add_argument(
"--output-dir",
required=True,
help="Directory for timesheet_report.md.",
)
return parser
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))

View file

@ -0,0 +1,649 @@
#!/usr/bin/env python3
"""Fetch Tempo worklogs and compute timesheet completion for a given period."""
from __future__ import annotations
import argparse
import base64
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:
"""Load the first .env file found from the given paths.
Sets missing keys into os.environ (existing env vars take priority).
Returns the path that was loaded, or None if none were found.
"""
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
# ── Utilities ─────────────────────────────────────────────────────────────────
def clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def normalize_key(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", "_", clean_text(value).lower()).strip("_")
# ── Period parsing ────────────────────────────────────────────────────────────
def parse_period(period_str: str, today: date | None = None) -> tuple[date, date, str]:
"""Return (start, end, human_label) for the given period string.
Supported keywords:
last-week Previous MondayFriday
last-month Previous calendar month
last-year Previous calendar year
yesterday Yesterday only
current-week Monday of this week to yesterday
current-month 1st of this month to yesterday
current-year 1st January of this year to yesterday
YYYY-MM-DD:YYYY-MM-DD Explicit date range
For current-* periods the end date is always yesterday so the current
in-progress day is excluded. If yesterday falls before the period start
(e.g. calling current-week on a Monday), end < start and the period
will contain zero working days a warning is printed by run().
"""
if today is None:
today = date.today()
yesterday = today - timedelta(days=1)
s = period_str.strip().lower()
if s == "last-week":
last_monday = today - timedelta(days=today.weekday() + 7)
return last_monday, last_monday + timedelta(days=4), "last week"
if s == "last-month":
end = today.replace(day=1) - timedelta(days=1)
return end.replace(day=1), end, "last month"
if s == "last-year":
return date(today.year - 1, 1, 1), date(today.year - 1, 12, 31), "last year"
if s == "yesterday":
return yesterday, yesterday, "yesterday"
if s == "current-week":
start = today - timedelta(days=today.weekday())
return start, yesterday, "current week to date"
if s == "current-month":
return today.replace(day=1), yesterday, "current month to date"
if s == "current-year":
return date(today.year, 1, 1), yesterday, "current year to date"
if ":" in s:
parts = s.split(":", 1)
start = date.fromisoformat(parts[0].strip())
end = date.fromisoformat(parts[1].strip())
return start, end, f"{start} to {end}"
raise ValueError(
f"Unknown period '{period_str}'. "
f"Use last-week, last-month, last-year, yesterday, "
f"current-week, current-month, current-year, or YYYY-MM-DD:YYYY-MM-DD."
)
# ── Public holidays ───────────────────────────────────────────────────────────
def get_public_holidays(country: str, years: set[int]) -> set[date]:
"""Fetch public holidays from date.nager.at for the given country and years.
country is an ISO 3166-1 alpha-2 code (e.g. PL, GB, DE, FR).
Returns an empty set with a warning if the country is not supported.
"""
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": "timesheet-checker/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
items = json.loads(resp.read())
year_holidays = {date.fromisoformat(item["date"]) for item in items}
holidays |= year_holidays
print(f" {country.upper()} {year}: {len(year_holidays)} public holidays")
except urllib.error.HTTPError as exc:
if exc.code == 404:
print(
f"Warning: country '{country.upper()}' not supported by date.nager.at "
f"— no holidays will be excluded for {year}.",
file=sys.stderr,
)
else:
print(f"Warning: could not fetch holidays for {country.upper()} {year}: {exc}", file=sys.stderr)
except urllib.error.URLError as exc:
print(f"Warning: could not fetch holidays for {country.upper()} {year}: {exc}", file=sys.stderr)
return holidays
def get_working_days(start: date, end: date, holidays: set[date]) -> list[date]:
"""Return weekdays (MonFri) in [start, end] that are not public holidays."""
result = []
current = start
while current <= end:
if current.weekday() < 5 and current not in holidays:
result.append(current)
current += timedelta(days=1)
return result
# ── Employees config ──────────────────────────────────────────────────────────
def is_table_row(line: str) -> bool:
s = line.strip()
return s.startswith("|") and s.endswith("|")
def split_table_row(line: str) -> list[str]:
s = line.strip().lstrip("|").rstrip("|")
return [c.strip() for c in s.split("|")]
def is_separator_row(cells: list[str]) -> bool:
return all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in cells)
def parse_markdown_tables(path: Path) -> list[dict[str, Any]]:
lines = path.read_text(encoding="utf-8").splitlines()
tables: list[dict[str, Any]] = []
i = 0
while i < len(lines):
if not is_table_row(lines[i]):
i += 1
continue
block: list[str] = []
while i < len(lines) and is_table_row(lines[i]):
block.append(lines[i])
i += 1
if len(block) < 3:
continue
headers = [normalize_key(c) for c in split_table_row(block[0])]
if not is_separator_row(split_table_row(block[1])):
continue
rows = []
for line in block[2:]:
vals = split_table_row(line)
if len(vals) != len(headers):
raise ValueError(f"Malformed table row in {path}: {line!r}")
rows.append(dict(zip(headers, vals)))
tables.append({"headers": headers, "rows": rows})
return tables
def find_table(tables: list[dict[str, Any]], required_headers: set[str]) -> list[dict[str, str]]:
for t in tables:
if required_headers.issubset(set(t["headers"])):
return t["rows"]
raise ValueError(f"No table with headers {sorted(required_headers)} found in employees file.")
def load_employees(path: Path) -> list[dict[str, Any]]:
tables = parse_markdown_tables(path)
# Load defaults (optional table with setting/value columns).
defaults: dict[str, Any] = {"country": "PL", "expected_hours_per_day": 8.0}
try:
for row in find_table(tables, {"setting", "value"}):
key = normalize_key(row["setting"])
val = clean_text(row["value"])
if not val:
continue
if key == "country":
defaults["country"] = val.upper()
elif key == "expected_hours_per_day":
try:
defaults["expected_hours_per_day"] = float(val)
except ValueError:
print(f"Warning: invalid default expected_hours_per_day '{val}' — using 8.", file=sys.stderr)
except ValueError:
pass # no defaults table; built-in defaults apply
rows = find_table(tables, {"name", "email"})
employees = []
for row in rows:
name = clean_text(row["name"])
email = clean_text(row["email"]).lower()
if not name or not email:
continue
raw_h = clean_text(row.get("expected_hours_per_day", ""))
try:
expected_h = float(raw_h) if raw_h else defaults["expected_hours_per_day"]
except ValueError:
expected_h = defaults["expected_hours_per_day"]
raw_country = clean_text(row.get("country", "")).upper()
country = raw_country if raw_country else defaults["country"]
raw_start = clean_text(row.get("start_date", ""))
start_date: date | None = None
if raw_start:
try:
start_date = date.fromisoformat(raw_start)
except ValueError:
print(
f"Warning: could not parse start_date '{raw_start}' for {name} — ignoring.",
file=sys.stderr,
)
raw_term = clean_text(row.get("termination_date", ""))
termination_date: date | None = None
if raw_term:
try:
termination_date = date.fromisoformat(raw_term)
except ValueError:
print(
f"Warning: could not parse termination_date '{raw_term}' for {name} — ignoring.",
file=sys.stderr,
)
employees.append({
"name": name,
"email": email,
"expected_hours_per_day": expected_h,
"country": country,
"start_date": start_date,
"termination_date": termination_date,
})
if not employees:
raise ValueError(f"No employees found in {path}. Check the table format.")
return employees
# ── Jira user lookup ──────────────────────────────────────────────────────────
def resolve_account_ids(
emails: list[str],
jira_base_url: str,
jira_email: str,
jira_token: str,
) -> dict[str, str]:
"""Return {email: account_id} by searching Jira's user API for each address."""
credentials = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"}
resolved: dict[str, str] = {}
not_found: list[str] = []
for email in emails:
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=10"
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 Unauthorized.\n"
"Check JIRA_EMAIL and JIRA_API_TOKEN (generate at id.atlassian.com).",
file=sys.stderr,
)
sys.exit(1)
body = exc.read().decode("utf-8", errors="replace")
print(f"Error: Jira API returned {exc.code} for {email}: {body}", file=sys.stderr)
sys.exit(1)
match = next(
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
None,
)
if match:
resolved[email] = match["accountId"]
print(f" {email}{match['accountId']}")
else:
not_found.append(email)
if not_found:
print(
f"Error: Could not find Jira account for: {', '.join(not_found)}\n"
"Check the email addresses in employees.md or your JIRA_BASE_URL.",
file=sys.stderr,
)
sys.exit(1)
return resolved
# ── Tempo API ─────────────────────────────────────────────────────────────────
def fetch_tempo_worklogs(
start: date,
end: date,
token: str,
base_url: str,
) -> dict[str, dict[str, float]]:
"""Return {account_id: {date_str: hours}} for ALL reporters in the period.
Paginates automatically. Date strings are ISO format (YYYY-MM-DD).
"""
totals: dict[str, dict[str, float]] = {}
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
next_url: str | None = f"{base_url}/worklogs?from={start}&to={end}&limit=1000"
page = 0
while next_url:
page += 1
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 Unauthorized. Check your TEMPO_API_TOKEN.",
file=sys.stderr,
)
else:
print(f"Error: Tempo API returned {exc.code}: {body}", file=sys.stderr)
sys.exit(1)
for worklog in data.get("results", []):
aid = worklog.get("author", {}).get("accountId", "")
date_str = worklog.get("startDate", "")
if aid and date_str:
if aid not in totals:
totals[aid] = {}
totals[aid][date_str] = (
totals[aid].get(date_str, 0.0) + worklog.get("timeSpentSeconds", 0) / 3600
)
next_url = data.get("metadata", {}).get("next")
print(f"Fetched worklogs (pages: {page})")
return totals
def resolve_account_info(
account_ids: list[str],
jira_base_url: str,
jira_email: str,
jira_token: str,
) -> dict[str, dict]:
"""Return {account_id: {display_name, email}} for the given Jira account IDs."""
credentials = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"}
result: dict[str, dict] = {}
for aid in account_ids:
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user?accountId={aid}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
user = json.loads(resp.read())
result[aid] = {
"display_name": user.get("displayName", ""),
"email": user.get("emailAddress", ""),
}
except (urllib.error.HTTPError, urllib.error.URLError):
result[aid] = {"display_name": f"Unknown ({aid})", "email": ""}
return result
# ── Stats computation ─────────────────────────────────────────────────────────
def compute_stats(
employees: list[dict[str, Any]],
period_start: date,
period_end: date,
holidays_by_country: dict[str, set[date]],
worklogs: dict[str, dict[str, float]],
) -> list[dict[str, Any]]:
results = []
for emp in employees:
holidays = holidays_by_country.get(emp["country"], set())
all_working_days = get_working_days(period_start, period_end, holidays)
emp_start: date | None = emp.get("start_date")
emp_term: date | None = emp.get("termination_date")
emp_working_days = all_working_days
if emp_start:
emp_working_days = [d for d in emp_working_days if d >= emp_start]
if emp_term:
emp_working_days = [d for d in emp_working_days if d <= emp_term]
expected_h = emp["expected_hours_per_day"] * len(emp_working_days)
# Partition logged hours into in-range vs out-of-range
date_logs = worklogs.get(emp["account_id"], {})
hours_in_range = 0.0
hours_before_start = 0.0
hours_after_termination = 0.0
for date_str, h in date_logs.items():
d = date.fromisoformat(date_str)
if emp_start and d < emp_start:
hours_before_start += h
elif emp_term and d > emp_term:
hours_after_termination += h
else:
hours_in_range += h
reported_h = round(hours_in_range, 2)
hours_before_start = round(hours_before_start, 2)
hours_after_termination = round(hours_after_termination, 2)
missing_h = round(max(0.0, expected_h - reported_h), 2)
pct = round((reported_h / expected_h * 100) if expected_h > 0 else 100.0, 1)
results.append({
"name": emp["name"],
"account_id": emp["account_id"],
"country": emp["country"],
"start_date": emp_start.isoformat() if emp_start else None,
"termination_date": emp_term.isoformat() if emp_term else None,
"effective_working_days": len(emp_working_days),
"expected_hours": expected_h,
"reported_hours": reported_h,
"hours_before_start": hours_before_start,
"hours_after_termination": hours_after_termination,
"missing_hours": missing_h,
"completion_pct": pct,
})
return sorted(results, key=lambda r: r["missing_hours"], reverse=True)
# ── Entry point ───────────────────────────────────────────────────────────────
def _resolve_employees_path(cli_value: str | None) -> Path | None:
"""Return the employees file to use, or None if not found.
Search order:
1. Explicit --employees argument (if given)
2. employees.md in the current working directory
3. references/employees.md in the skill folder (next to this script)
"""
if cli_value:
return Path(cli_value)
skill_dir = Path(__file__).parent.parent
for candidate in (
Path.cwd() / "employees.md",
skill_dir / "references" / "employees.md",
):
if candidate.exists():
return candidate
return None
def run(args: argparse.Namespace) -> int:
# Load .env from working directory first, then skill directory as fallback.
# Existing environment variables always take priority over .env values.
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 = args.tempo_token or os.environ.get("TEMPO_API_TOKEN", "")
jira_base_url = args.jira_base_url or os.environ.get("JIRA_BASE_URL", "")
jira_email = args.jira_email or os.environ.get("JIRA_EMAIL", "")
jira_token = args.jira_token or os.environ.get("JIRA_API_TOKEN", "")
missing = [
name for name, val in [
("TEMPO_API_TOKEN", tempo_token),
("JIRA_BASE_URL", jira_base_url),
("JIRA_EMAIL", jira_email),
("JIRA_API_TOKEN", jira_token),
] if not val
]
if missing:
print(
f"Error: missing required credentials: {', '.join(missing)}\n"
"Set them as environment variables or pass via CLI flags.\n"
"See README.md for how to obtain each credential.",
file=sys.stderr,
)
return 1
start, end, label = parse_period(args.period)
print(f"Period: {label} ({start} to {end})")
employees_path = _resolve_employees_path(args.employees)
if employees_path is None:
skill_dir = Path(__file__).parent.parent
print(
"Error: No employees.md found.\n"
f"Looked in:\n"
f" {Path.cwd() / 'employees.md'}\n"
f" {skill_dir / 'references' / 'employees.md'}\n"
"Create one in your project directory or pass --employees PATH.",
file=sys.stderr,
)
return 1
employees = load_employees(employees_path)
print(f"Employees loaded: {len(employees)} (from {employees_path})")
countries = {e["country"] for e in employees}
years = {start.year, end.year}
print(f"Fetching public holidays from date.nager.at for: {', '.join(sorted(countries))} ...")
holidays_by_country = {c: get_public_holidays(c, years) for c in countries}
print("Resolving Jira account IDs from email addresses...")
email_to_id = resolve_account_ids(
[e["email"] for e in employees], jira_base_url, jira_email, jira_token
)
for emp in employees:
emp["account_id"] = email_to_id[emp["email"]]
known_ids = {e["account_id"] for e in employees}
print(f"Fetching Tempo worklogs from {args.tempo_base_url}...")
all_worklogs = fetch_tempo_worklogs(start, end, tempo_token, args.tempo_base_url)
# Detect reporters not in employees.md
unknown_ids = [aid for aid in all_worklogs if aid not in known_ids]
unknown_reporters: list[dict] = []
if unknown_ids:
print(f"Resolving {len(unknown_ids)} unknown reporter(s) from Jira...")
account_info = resolve_account_info(unknown_ids, jira_base_url, jira_email, jira_token)
for aid in unknown_ids:
info = account_info[aid]
total_h = round(sum(all_worklogs[aid].values()), 2)
unknown_reporters.append({
"account_id": aid,
"display_name": info["display_name"],
"email": info["email"],
"reported_hours": total_h,
})
print(f" Not in employees.md: {info['display_name']} ({info['email']}) — {total_h:.1f}h")
unknown_reporters.sort(key=lambda r: r["reported_hours"], reverse=True)
stats = compute_stats(employees, start, end, holidays_by_country, all_worklogs)
total_expected = sum(r["expected_hours"] for r in stats)
total_reported = round(sum(r["reported_hours"] for r in stats), 2)
overall_pct = round((total_reported / total_expected * 100) if total_expected > 0 else 100.0, 1)
# Annotate with email for readability
for stat in stats:
emp = next(e for e in employees if e["account_id"] == stat["account_id"])
stat["email"] = emp["email"]
working_days_by_country = {
c: len(get_working_days(start, end, holidays_by_country[c]))
for c in sorted(countries)
}
output = {
"period": {
"start": start.isoformat(),
"end": end.isoformat(),
"label": label,
"working_days_by_country": working_days_by_country,
},
"summary": {
"total_employees": len(stats),
"total_expected_hours": total_expected,
"total_reported_hours": total_reported,
"overall_completion_pct": overall_pct,
"employees_fully_complete": sum(1 for r in stats if r["completion_pct"] >= 100),
"employees_with_gaps": sum(1 for r in stats if r["completion_pct"] < 100),
},
"employees": stats,
"unknown_reporters": unknown_reporters,
}
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
out_path = output_dir / "timesheet_data.json"
out_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
print(f"Wrote {out_path}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Fetch Tempo worklogs and compute timesheet completion."
)
parser.add_argument(
"--period",
required=True,
help="Period to check: 'last-week', 'last-month', or 'YYYY-MM-DD:YYYY-MM-DD'.",
)
parser.add_argument(
"--employees",
help=(
"Path to the employees markdown config file. "
"If omitted, looks for employees.md in the current directory, "
"then references/employees.md in the skill folder."
),
)
parser.add_argument(
"--output-dir",
required=True,
help="Directory for timesheet_data.json.",
)
parser.add_argument(
"--tempo-token",
help="Tempo API token. Defaults to TEMPO_API_TOKEN env var.",
)
parser.add_argument(
"--tempo-base-url",
default="https://api.tempo.io/4",
help="Tempo API base URL. Default: https://api.tempo.io/4 (Tempo Cloud).",
)
parser.add_argument(
"--jira-base-url",
help="Jira base URL, e.g. https://company.atlassian.net. Defaults to JIRA_BASE_URL env var.",
)
parser.add_argument(
"--jira-email",
help="Email address used to authenticate with Jira. Defaults to JIRA_EMAIL env var.",
)
parser.add_argument(
"--jira-token",
help="Jira personal API token. Defaults to JIRA_API_TOKEN env var.",
)
return parser
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))