Files
marketplace/skills/use/scripts/fetch_issue.py
T
naudachu 6c6e0149ac feat: fetch_issue script for trimmed issue reads
Raw `tea issues -o json` / `tea api` dumps the full payload (avatars,
nested users, every comment) into the model context. The script writes
trimmed markdown to tmp/issue/<n>/ and prints only a compact index;
login comes from the operator pin, never an argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:06:09 +05:00

184 lines
6.3 KiB
Python

#!/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/<n>/data issue itself (metadata header + body)
tmp/issue/<n>/comments/ one file per comment: NNN-<comment-id>.md
Usage:
fetch_issue.py <key> [--repo owner/repo] [--out DIR]
<key> 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/<n>/")
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()