feat: local issue cache and draft-then-push workflow
Replace fetch_issue.py with four scripts around a flat, greppable cache in tmp/issues/. Planning stays offline and issues reach Gitea in one push: - issue_get.py: fetch by key or by filter (--milestone/--label/-q). The list endpoint carries issue bodies, so a whole milestone costs one request per 50 issues. Gitea silently ignores an unresolvable milestones= filter and returns the entire backlog, so the milestone is resolved up front and every returned issue is re-checked locally. --deps walks the dependency graph downwards via the structured sections plus native dependencies and writes tree-<slug>.md. - issue_push.py: validate a local draft against the canonical format, create missing labels with the right colors and exclusivity, POST, delete the draft. - issue_list.py: discovery to stdout, writes nothing. - issue_index.py: rebuild INDEX.md from what is on disk. Files use one metadata field per line with inline lists so plain grep works without a parser. This is a cache and a drafting area, not a mirror: no drift tracking, no sync back. Projects are not fetchable — the projects API is 404 on Gitea 1.26; documented alongside the milestone caveat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_get.py — pull Gitea issues into the local grep cache under tmp/issues/.
|
||||
|
||||
Token-saving fetcher: instead of dumping raw API JSON into the conversation it
|
||||
writes flat, grep-friendly markdown and prints a compact index. Read only the
|
||||
files the task needs.
|
||||
|
||||
tmp/issues/<n>.md metadata block + `# Title` + body
|
||||
tmp/issues/<n>.comments.md comments (only with --comments)
|
||||
tmp/issues/tree-<slug>.md dependency map (only with --deps)
|
||||
tmp/issues/INDEX.md table of everything cached (auto-rebuilt)
|
||||
|
||||
Two ways to name what to fetch:
|
||||
|
||||
issue_get.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL
|
||||
issue_get.py --milestone 6 by filter: whole milestone in ONE request
|
||||
issue_get.py --label type/bug --state all
|
||||
issue_get.py -q sqlc --limit 20
|
||||
|
||||
Filter mode costs one request per 50 issues — the list payload already carries
|
||||
the bodies. Gitea silently ignores an unresolvable `milestones=` filter and
|
||||
returns the whole backlog, so the milestone is resolved up front and every
|
||||
issue is re-checked locally. Projects are NOT filterable: the projects API is
|
||||
not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
|
||||
|
||||
Other flags:
|
||||
--deps [--depth N] walk dependencies downwards and write the tree map
|
||||
--comments also fetch comments (single issue only)
|
||||
--cached skip issues already on disk instead of refetching
|
||||
--repo owner/repo default: auto-detect from the CWD git remote
|
||||
|
||||
--deps follows the structured `## Depends on` / `## Issues` sections plus
|
||||
Gitea's native issue dependencies. Prose `#N` mentions are ignored on purpose.
|
||||
Who depends on ME is a grep, not a flag:
|
||||
|
||||
grep -ln 'depends:.*#42' tmp/issues/*.md
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue_index # noqa: E402
|
||||
from _tea import (ISSUE_ROOT, comments_path, deps_of, die, issue_path, # noqa: E402
|
||||
list_issues, paginate, parse_key, parse_meta, read_file,
|
||||
render_comments, render_issue, repo_base, require_login,
|
||||
tea_api, tree_path, write_file)
|
||||
|
||||
|
||||
def native_deps(login, base, n):
|
||||
"""Gitea's own issue dependencies (may be unsupported -> empty)."""
|
||||
got = tea_api(login, "%s/issues/%d/dependencies" % (base, n), allow_fail=True)
|
||||
return [i["number"] for i in got] if isinstance(got, list) else []
|
||||
|
||||
|
||||
def store(login, base, iss, root, with_native):
|
||||
"""Write one issue to the cache; return its dependency numbers."""
|
||||
n = iss["number"]
|
||||
extra = native_deps(login, base, n) if with_native else []
|
||||
write_file(issue_path(root, n), render_issue(iss, extra))
|
||||
return list(dict.fromkeys(deps_of(iss) + extra))
|
||||
|
||||
|
||||
def fetch_issue(login, base, n):
|
||||
iss = tea_api(login, "%s/issues/%d" % (base, n))
|
||||
if not isinstance(iss, dict) or "number" not in iss:
|
||||
die("issue #%d not found" % n)
|
||||
return iss
|
||||
|
||||
|
||||
def fetch_comments(login, base, n, root):
|
||||
comments = paginate(login, "%s/issues/%d/comments" % (base, n))
|
||||
if comments:
|
||||
return write_file(comments_path(root, n), render_comments(n, comments))
|
||||
if os.path.isfile(comments_path(root, n)):
|
||||
os.remove(comments_path(root, n)) # stale file from an earlier fetch
|
||||
return None
|
||||
|
||||
|
||||
def cached_issue(root, n):
|
||||
"""(title, state, labels, deps) from an already-fetched file, or None."""
|
||||
path = issue_path(root, n)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
meta, title, _body = parse_meta(read_file(path))
|
||||
deps = meta.get("depends") or []
|
||||
if isinstance(deps, str):
|
||||
deps = [deps]
|
||||
labels = meta.get("labels") or []
|
||||
if isinstance(labels, str):
|
||||
labels = [labels]
|
||||
return {"title": title, "state": meta.get("state", ""), "labels": labels,
|
||||
"deps": [int(d.lstrip("#")) for d in deps if d.lstrip("#").isdigit()]}
|
||||
|
||||
|
||||
def summary(iss):
|
||||
return {"title": iss.get("title", ""), "state": iss.get("state", ""),
|
||||
"labels": [l.get("name", "") for l in iss.get("labels") or []]}
|
||||
|
||||
|
||||
def type_of(labels):
|
||||
for l in labels:
|
||||
if l.startswith("type/"):
|
||||
return l.split("/", 1)[1]
|
||||
return "-"
|
||||
|
||||
|
||||
def render_tree(roots, nodes, edges):
|
||||
"""ASCII map of the walked graph; repeated nodes collapse to (see above)."""
|
||||
lines, seen = [], set()
|
||||
|
||||
def label(n):
|
||||
s = nodes.get(n)
|
||||
if not s:
|
||||
return "#%d (not fetched — beyond --depth)" % n
|
||||
tail = " (see above)" if n in seen and edges.get(n) else ""
|
||||
return "#%d [%s] %s — %s %d.md%s" % (
|
||||
n, type_of(s["labels"]), s["title"], s["state"], n, tail)
|
||||
|
||||
def walk(n, prefix, is_last, is_root):
|
||||
connector = "" if is_root else ("└── " if is_last else "├── ")
|
||||
lines.append(prefix + connector + label(n))
|
||||
if n in seen:
|
||||
return
|
||||
seen.add(n)
|
||||
kids = edges.get(n) or []
|
||||
child_prefix = prefix if is_root else prefix + (" " if is_last else "│ ")
|
||||
for i, k in enumerate(kids):
|
||||
walk(k, child_prefix, i == len(kids) - 1, False)
|
||||
|
||||
for r in roots:
|
||||
if r in seen:
|
||||
continue # already shown as somebody's child — one tree, not two
|
||||
walk(r, "", True, True)
|
||||
lines.append("")
|
||||
title = "#%d" % roots[0] if len(roots) == 1 else "%d issues" % len(roots)
|
||||
return "# Dependency tree for %s\n\n```\n%s```\n" % (title, "\n".join(lines))
|
||||
|
||||
|
||||
def slugify(s):
|
||||
return re.sub(r'[^a-z0-9]+', '-', str(s).lower()).strip("-") or "filter"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Fetch Gitea issues into tmp/issues/")
|
||||
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
|
||||
ap.add_argument("--milestone", help="fetch a whole milestone (id or title)")
|
||||
ap.add_argument("--label", action="append", default=[], help="filter by label; repeat for AND")
|
||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
||||
help="filter mode only (default: open)")
|
||||
ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)")
|
||||
ap.add_argument("--deps", action="store_true", help="walk dependencies downwards")
|
||||
ap.add_argument("--depth", type=int, default=3, help="max walk depth (default: 3)")
|
||||
ap.add_argument("--comments", action="store_true",
|
||||
help="also fetch comments (single issue only)")
|
||||
ap.add_argument("--cached", action="store_true",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=ISSUE_ROOT, help="cache root (default: tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
filtered = bool(args.milestone or args.label or args.query)
|
||||
if args.keys and filtered:
|
||||
die("pass issue keys OR filters, not both")
|
||||
if not args.keys and not filtered:
|
||||
die("nothing to fetch: pass issue keys, or --milestone / --label / -q")
|
||||
|
||||
root, login = args.out, require_login()
|
||||
nodes, edges, fetched, cached_hits = {}, {}, [], []
|
||||
|
||||
# ---- seeds -----------------------------------------------------------
|
||||
if filtered:
|
||||
base = repo_base(args.repo)
|
||||
seeds_iss, ms_title = list_issues(
|
||||
login, base, state=args.state, labels=args.label, query=args.query,
|
||||
milestone=args.milestone, limit=args.limit)
|
||||
if not seeds_iss:
|
||||
die("no issues match that filter")
|
||||
what = []
|
||||
if args.milestone:
|
||||
what.append("milestone %s" % ms_title)
|
||||
what += ["label %s" % l for l in args.label]
|
||||
if args.query:
|
||||
what.append("q=%r" % args.query)
|
||||
slug = slugify(ms_title or (args.label[0] if args.label else args.query))
|
||||
sys.stderr.write("%d issue(s) match %s (%s)\n"
|
||||
% (len(seeds_iss), " + ".join(what), args.state))
|
||||
else:
|
||||
repos = {parse_key(k)[1] for k in args.keys} - {None}
|
||||
if len(repos) > 1:
|
||||
die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos)))
|
||||
base = repo_base(args.repo or (repos.pop() if repos else None))
|
||||
seeds_iss = None # fetched below, one by one
|
||||
seeds_n = [parse_key(k)[0] for k in args.keys]
|
||||
slug = str(seeds_n[0]) if len(seeds_n) == 1 else "-".join(str(n) for n in seeds_n[:4])
|
||||
|
||||
if args.comments and ((seeds_iss and len(seeds_iss) > 1) or
|
||||
(seeds_iss is None and len(args.keys) > 1)):
|
||||
die("--comments works on a single issue; loop over the numbers instead")
|
||||
|
||||
if seeds_iss is not None:
|
||||
seeds_n = []
|
||||
for iss in seeds_iss:
|
||||
n = iss["number"]
|
||||
seeds_n.append(n)
|
||||
hit = cached_issue(root, n) if args.cached else None
|
||||
if hit:
|
||||
nodes[n], edges[n] = hit, hit["deps"]
|
||||
cached_hits.append(n)
|
||||
else:
|
||||
nodes[n] = summary(iss)
|
||||
edges[n] = store(login, base, iss, root, args.deps)
|
||||
fetched.append(n)
|
||||
|
||||
# ---- walk ------------------------------------------------------------
|
||||
queue = [(n, 0) for n in seeds_n]
|
||||
visited = set(nodes)
|
||||
while queue:
|
||||
n, depth = queue.pop(0)
|
||||
if n not in visited:
|
||||
visited.add(n)
|
||||
hit = cached_issue(root, n) if args.cached else None
|
||||
if hit:
|
||||
nodes[n], edges[n] = hit, hit["deps"]
|
||||
cached_hits.append(n)
|
||||
else:
|
||||
iss = fetch_issue(login, base, n)
|
||||
nodes[n] = summary(iss)
|
||||
edges[n] = store(login, base, iss, root, args.deps)
|
||||
fetched.append(n)
|
||||
if args.deps and depth < args.depth:
|
||||
queue.extend((d, depth + 1) for d in edges.get(n, []) if d not in visited)
|
||||
|
||||
cpath = None
|
||||
if args.comments:
|
||||
cpath = fetch_comments(login, base, seeds_n[0], root)
|
||||
|
||||
tpath = write_file(tree_path(root, slug), render_tree(seeds_n, nodes, edges)) \
|
||||
if args.deps else None
|
||||
index_path, _ = issue_index.build(root)
|
||||
|
||||
# Compact output — the only thing that lands in the model's context.
|
||||
for n in sorted(nodes):
|
||||
s = nodes[n]
|
||||
print("#%d [%s] %s — %s %s%s" % (
|
||||
n, ", ".join(s["labels"]) or "no labels", s["title"], s["state"],
|
||||
issue_path(root, n), " (cached)" if n in cached_hits else ""))
|
||||
if cpath:
|
||||
print("comments: %s" % cpath)
|
||||
if tpath:
|
||||
print("tree: %s" % tpath)
|
||||
print("index: %s" % index_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user