diff --git a/skills/use/SKILL.md b/skills/use/SKILL.md index f809ac2..94069a4 100644 --- a/skills/use/SKILL.md +++ b/skills/use/SKILL.md @@ -46,6 +46,39 @@ The pin takes effect immediately — no restart. Only `tea logins list` and per-project by the operator (see `/tea:auth`) and injected by the guard. Config lives in `$XDG_CONFIG_HOME/tea`. +## Reading an issue: use the fetch script, not raw tea calls + +To read an existing issue (its body, its discussion), do NOT run +`tea issues -o json` or `tea api .../issues/` directly — the full JSON +payload (avatars, nested user objects, every comment body) lands in your +context whether you need it or not. Instead run the bundled script; the only +input it needs is the issue key: + +```bash +python3 /scripts/fetch_issue.py 42 +``` + +Key forms: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo defaults +to the current directory's git remote (add `--repo owner/repo` outside one). + +It writes trimmed markdown files locally and prints only a compact index: + +``` +tmp/issue/42/data issue: metadata header + body +tmp/issue/42/comments/ one file per comment: NNN-.md +``` + +Then Read just the files the task needs — often the `data` file alone, or a +single comment picked from the index (author + date per line). Each comment +file carries its `comment-id`, ready for a `PATCH` via `tea api`. + +Notes: +- No `--login` on the script call: the script resolves the operator's pinned + login itself from `.claude/settings.local.json` — same source as the + tea-guard hook. No pin → it exits with a pointer to `/tea:auth`. +- Every run refetches fresh and wipes the issue's `comments/` dir, so stale + files never survive. + ## Index - [tea CLI overview](references/tea/index.md) — global flags, common options, output formats diff --git a/skills/use/scripts/fetch_issue.py b/skills/use/scripts/fetch_issue.py new file mode 100644 index 0000000..78d7d7b --- /dev/null +++ b/skills/use/scripts/fetch_issue.py @@ -0,0 +1,183 @@ +#!/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()