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

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()))