Add four skills for Tempo/KSeF timesheet and invoice workflows: - check-my-timesheet: show/log the current user's Tempo time entries - timesheet-checker: audit timesheet completion across all reporters - invoice-checker: pull KSeF invoices, check contractors + MF white list - invoice-prep: summarize Tempo hours per Jira project for invoicing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
12 KiB
Python
280 lines
12 KiB
Python
#!/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()))
|