#!/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()))