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