#!/usr/bin/env python3 """ fetch_issue.py — pull one Gitea issue (+ all comments) to local files. Token-saving fetcher for Claude sessions: instead of dumping raw API JSON into the conversation, it writes trimmed markdown files under tmp/issue/ and prints only a compact index. Read the files you actually need. tmp/issue//data issue itself (metadata header + body) tmp/issue//comments/ one file per comment: NNN-.md Usage: fetch_issue.py [--repo owner/repo] [--out DIR] 42 | #42 | owner/repo#42 | https://host/owner/repo/issues/42 Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up from CWD — the same file /tea:auth writes and the tea-guard hook reads. The script never accepts a login argument: the operator's pin is the only identity it will use. No pin -> exit with a pointer to /tea:auth. """ import argparse import json import os import re import shutil import subprocess import sys def die(msg, code=1): sys.stderr.write("fetch_issue: " + msg + "\n") sys.exit(code) def find_pin(start_dir): """Walk up from start_dir; return login from the first .claude/settings.local.json carrying a non-empty env.GITEA_LOGIN.""" d = os.path.abspath(start_dir or ".") while True: p = os.path.join(d, ".claude", "settings.local.json") if os.path.isfile(p): try: with open(p) as f: v = (json.load(f).get("env") or {}).get("GITEA_LOGIN") if isinstance(v, str) and v.strip(): return v.strip() except Exception: pass parent = os.path.dirname(d) if parent == d: return None d = parent def parse_key(key): """Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / URL.""" key = key.strip() m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key) if m: return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2)) m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key) if m: return int(m.group(2)), m.group(1) m = re.match(r'^#?(\d+)$', key) if m: return int(m.group(1)), None die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key) def tea_api(login, endpoint): """GET via `tea api`, return parsed JSON.""" cmd = ["tea", "api", "--login", login, endpoint] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: die("`tea api %s` failed:\n%s" % (endpoint, (r.stderr or r.stdout).strip())) try: return json.loads(r.stdout) except json.JSONDecodeError: die("`tea api %s` returned non-JSON:\n%s" % (endpoint, r.stdout[:500])) def day(iso): return (iso or "")[:10] def issue_markdown(iss): labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "none" assignees = ", ".join(a.get("login", "") for a in iss.get("assignees") or []) or "none" milestone = (iss.get("milestone") or {}).get("title") or "none" lines = [ "#%d %s" % (iss["number"], iss.get("title", "")), "state: %s" % iss.get("state", ""), "labels: %s" % labels, "author: %s" % (iss.get("user") or {}).get("login", ""), "assignees: %s" % assignees, "milestone: %s" % milestone, "created: %s" % iss.get("created_at", ""), "updated: %s" % iss.get("updated_at", ""), "url: %s" % iss.get("html_url", ""), "comments: %d" % iss.get("comments", 0), "", "---", "", iss.get("body") or "(no body)", "", ] return "\n".join(lines) def comment_markdown(c): lines = [ "comment-id: %d" % c["id"], "author: %s" % (c.get("user") or {}).get("login", ""), "created: %s" % c.get("created_at", ""), "updated: %s" % c.get("updated_at", ""), "", "---", "", c.get("body") or "(empty)", "", ] return "\n".join(lines) def main(): ap = argparse.ArgumentParser(description="Fetch a Gitea issue + comments to tmp/issue//") ap.add_argument("key", help="issue key: 42, #42, owner/repo#42, or issue URL") ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") ap.add_argument("--out", default=os.path.join("tmp", "issue"), help="output root (default: tmp/issue)") args = ap.parse_args() number, key_repo = parse_key(args.key) repo = args.repo or key_repo # None -> let tea fill {owner}/{repo} from CWD base = "repos/%s" % repo if repo else "repos/{owner}/{repo}" login = find_pin(os.getcwd()) if not login: die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") iss = tea_api(login, "%s/issues/%d" % (base, number)) comments = [] page = 1 while page <= 40: batch = tea_api(login, "%s/issues/%d/comments?page=%d&limit=50" % (base, number, page)) if not isinstance(batch, list) or not batch: break comments.extend(batch) if len(batch) < 50: break page += 1 root = os.path.join(args.out, str(number)) cdir = os.path.join(root, "comments") shutil.rmtree(cdir, ignore_errors=True) # drop stale comments from earlier fetches os.makedirs(cdir, exist_ok=True) data_path = os.path.join(root, "data") with open(data_path, "w") as f: f.write(issue_markdown(iss)) index = [] for i, c in enumerate(comments, 1): name = "%03d-%d.md" % (i, c["id"]) with open(os.path.join(cdir, name), "w") as f: f.write(comment_markdown(c)) index.append((name, (c.get("user") or {}).get("login", ""), day(c.get("created_at")))) # Compact index — the only thing that lands in the model's context. labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "no labels" print("#%d %s [%s] %s — %s, updated %s" % ( iss["number"], iss.get("title", ""), iss.get("state", ""), labels, (iss.get("user") or {}).get("login", ""), day(iss.get("updated_at")))) print(data_path) print("comments: %d" % len(comments)) for name, author, created in index: print("%s %s %s" % (os.path.join(cdir, name), author, created)) if __name__ == "__main__": main()