142 lines
5.1 KiB
Python
Executable File
142 lines
5.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Thin client for the company timesheet REST API.
|
|
|
|
Auth is a personal access token in $HOURLOG_TOKEN (scopes: profile:read,
|
|
schedule:read, and schedule:write only if you intend to submit). The base URL
|
|
is $HOURLOG_API. Both live in ~/.env.claude — never pass a token on argv, it
|
|
lands in shell history and in the process table.
|
|
|
|
Commands:
|
|
whoami GET /auth/me
|
|
projects GET /my/projects
|
|
categories GET /investment-categories
|
|
schedule --start D --end D GET /my-schedule
|
|
overdue GET /my-schedule/overdue
|
|
confirm --entry ID --hours H PUT /day-entries/ID
|
|
log --project ID --dates D,D --hours H POST /my/day-entries
|
|
log --category ID --dates D,D --hours H POST /my/day-entries
|
|
raw METHOD PATH [JSON] escape hatch
|
|
|
|
Every write takes --dry-run, which prints the exact request and sends nothing.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
|
|
def request(method, path, body=None, dry=False):
|
|
api = os.environ.get("HOURLOG_API")
|
|
if not api:
|
|
sys.exit("HOURLOG_API is not set — add the API base URL to "
|
|
"~/.env.claude (it ends in /api)")
|
|
url = api.rstrip("/") + path
|
|
payload = json.dumps(body).encode() if body is not None else None
|
|
if dry:
|
|
print(f"DRY RUN {method} {url}")
|
|
if body is not None:
|
|
print(json.dumps(body, indent=1))
|
|
return None
|
|
|
|
token = os.environ.get("HOURLOG_TOKEN")
|
|
if not token:
|
|
sys.exit("HOURLOG_TOKEN is not set — add it to ~/.env.claude "
|
|
"(create one under profile > personal access tokens)")
|
|
req = urllib.request.Request(url, data=payload, method=method, headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {token}",
|
|
})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
raw = r.read()
|
|
return json.loads(raw) if raw else None
|
|
except urllib.error.HTTPError as e:
|
|
detail = e.read().decode(errors="replace")[:400]
|
|
if e.code == 401:
|
|
sys.exit("401 — token missing, expired, or lacking the scope")
|
|
if e.code == 423:
|
|
sys.exit("423 — that period is locked; ops has to reopen it")
|
|
sys.exit(f"{e.code} {method} {path}: {detail}")
|
|
except urllib.error.URLError as e:
|
|
sys.exit(f"cannot reach the API: {e.reason}")
|
|
|
|
|
|
def show(obj):
|
|
json.dump(obj, sys.stdout, indent=1)
|
|
print()
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
sub.add_parser("whoami")
|
|
sub.add_parser("projects")
|
|
sub.add_parser("categories")
|
|
sub.add_parser("overdue")
|
|
|
|
s = sub.add_parser("schedule")
|
|
s.add_argument("--start", required=True)
|
|
s.add_argument("--end", required=True)
|
|
|
|
c = sub.add_parser("confirm")
|
|
c.add_argument("--entry", required=True, help="day entry id")
|
|
c.add_argument("--hours", required=True, help="decimal hours, e.g. 4 or 7.5")
|
|
c.add_argument("--dry-run", action="store_true")
|
|
|
|
log_p = sub.add_parser("log")
|
|
g = log_p.add_mutually_exclusive_group(required=True)
|
|
g.add_argument("--project", help="project id")
|
|
g.add_argument("--category", help="investment category id")
|
|
log_p.add_argument("--dates", required=True, help="comma-separated YYYY-MM-DD")
|
|
log_p.add_argument("--hours", required=True, help="decimal hours per day")
|
|
log_p.add_argument("--dry-run", action="store_true")
|
|
|
|
r = sub.add_parser("raw")
|
|
r.add_argument("method")
|
|
r.add_argument("path")
|
|
r.add_argument("body", nargs="?")
|
|
r.add_argument("--dry-run", action="store_true")
|
|
|
|
a = ap.parse_args()
|
|
|
|
if a.cmd == "whoami":
|
|
show(request("GET", "/auth/me"))
|
|
elif a.cmd == "projects":
|
|
show(request("GET", "/my/projects"))
|
|
elif a.cmd == "categories":
|
|
show(request("GET", "/investment-categories"))
|
|
elif a.cmd == "overdue":
|
|
show(request("GET", "/my-schedule/overdue"))
|
|
elif a.cmd == "schedule":
|
|
q = urllib.parse.urlencode({"start_date": a.start, "end_date": a.end})
|
|
show(request("GET", f"/my-schedule?{q}"))
|
|
elif a.cmd == "confirm":
|
|
body = {"day_entry": {"actual_hours": a.hours}}
|
|
out = request("PUT", f"/day-entries/{a.entry}", body, a.dry_run)
|
|
if out is not None:
|
|
show(out)
|
|
elif a.cmd == "log":
|
|
entry = {"dates": [d.strip() for d in a.dates.split(",") if d.strip()],
|
|
"hours": a.hours}
|
|
if a.project:
|
|
entry["project_id"] = a.project
|
|
else:
|
|
entry["investment_category_id"] = a.category
|
|
out = request("POST", "/my/day-entries", {"day_entry": entry}, a.dry_run)
|
|
if out is not None:
|
|
show(out)
|
|
elif a.cmd == "raw":
|
|
body = json.loads(a.body) if a.body else None
|
|
out = request(a.method.upper(), a.path, body, a.dry_run)
|
|
if out is not None:
|
|
show(out)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|