Files
yellowjacket/scripts/issue_fmt.py
yonlu ae82fd2233 chore(scripts): reach the issue tracker from the command line
Issues become this project's source of truth for what is wanted and what
is already being worked on, which puts "search the tracker" at the top of
every task rather than occasionally. Fifty-odd open issues make that a
real lookup, and a lookup nobody can remember the shape of is a lookup
that gets skipped -- the same way the CI log endpoint cost two sessions
to a tool that 404s.

Text reaches the API as JSON and never as shell, which is why the
formatting half is its own Python file: an issue body is arbitrary prose
carrying backticks, quotes and $, and every attempt to build that JSON
inside the shell ends in nested quoting nobody can verify. Same reasoning
that keeps release notes out of gitea-release.sh's argument list.

Claiming is an assignment, a label and a comment together, because any
one alone is a claim somebody has to go looking for. It resolves the
comment before it mutates anything -- reading it afterwards is how a
claim ends up half-made, with the issue saying it is taken without saying
by what work -- and refuses outright if somebody else holds it.

Three API shapes are pinned here because each fails quietly:

- Labels are resolved to ids rather than posted as names. Gitea accepts
  a list of unknown names with 200 and applies none of them, so a typo
  reports success and does nothing.
- The dependency endpoint takes a whole IssueMeta, not an index. A body
  of {"index": 88} answers 404, which reads exactly like a Gitea build
  without the feature.
- close drops Status/In Progress, or a claim outlives the work.

Refs #92
2026-08-18 16:23:39 -04:00

146 lines
4.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""JSON encoding and formatting for scripts/issue.sh.
It is a separate file rather than a heredoc for one reason: an issue body is
arbitrary prose, and every attempt to build that JSON inside the shell ends in
nested quoting nobody can read or verify. Here the shell passes only argv and
stdin, and every string that reaches the API is encoded by json.dumps.
A Gitea error is a JSON object with "message", and it arrives with HTTP 200 in
enough cases that printing it as data is how a wrong token scope reads as an
empty tracker. check() is what turns it into a non-zero exit instead.
"""
import json
import sys
import urllib.parse
def die(msg):
sys.exit("issue.sh: " + msg)
def load():
raw = sys.stdin.read()
if not raw.strip():
die("empty response from the API")
try:
return json.loads(raw)
except json.JSONDecodeError:
die("unreadable response: " + raw[:200])
def check(d):
if isinstance(d, dict) and "message" in d and "number" not in d:
die(d["message"])
return d
def emit(d):
json.dump(d, sys.stdout)
def names(items, key="name"):
return ", ".join(i[key] for i in items) or "-"
def main(argv):
cmd = argv[1] if len(argv) > 1 else ""
args = argv[2:]
if cmd == "check":
emit(check(load()))
elif cmd == "urlquote":
print(urllib.parse.quote(args[0]))
elif cmd == "login":
print(check(load())["login"])
elif cmd == "list":
for i in check(load()):
labels = ",".join(x["name"] for x in i["labels"])
who = ",".join(a["login"] for a in (i.get("assignees") or [])) or "-"
title = i["title"][:62]
print("#%-4d %-6s %-8s %-62s [%s]" % (i["number"], i["state"], who, title, labels))
elif cmd == "show":
d = check(load())
print("#%d %s" % (d["number"], d["title"]))
print("state: " + d["state"])
print("labels: " + names(d["labels"]))
print("assignees: " + names(d.get("assignees") or [], "login"))
print("url: " + d["html_url"])
print()
print(d.get("body") or "(no body)")
print()
elif cmd == "deps":
d = check(load())
if not d:
print(" (none)")
for i in d:
print(" #%d [%s] %s" % (i["number"], i["state"], i["title"][:70]))
elif cmd == "comments":
d = check(load())
if not d:
print(" (none)")
for c in d:
print(" %s (%s): %s" % (c["user"]["login"], c["created_at"][:10], c["body"][:600]))
elif cmd == "labels":
for label in check(load()):
print("%-24s %s" % (label["name"], (label.get("description") or "")[:60]))
elif cmd == "label-id":
have = {label["name"]: label["id"] for label in check(load())}
if args[0] not in have:
die("no such label: " + args[0])
print(have[args[0]])
elif cmd == "label-ids":
want = [s.strip() for s in args[0].split(",") if s.strip()]
have = {label["name"]: label["id"] for label in check(load())}
missing = [w for w in want if w not in have]
if missing:
die("no such label(s): " + ", ".join(missing))
emit([have[w] for w in want])
elif cmd == "wrap-body":
body = sys.stdin.read().strip()
if not body:
die("refusing to post an empty comment")
emit({"body": body})
elif cmd == "new-issue":
title, ids = args[0], json.loads(args[1])
body = sys.stdin.read().strip()
if not body:
die("an issue needs a body — the tracker is the record, not the title")
emit({"title": title, "body": body, "labels": ids})
elif cmd == "created":
d = check(load())
print("created #%d %s" % (d["number"], d["html_url"]))
elif cmd == "assignees":
print(",".join(a["login"] for a in (check(load()).get("assignees") or [])))
elif cmd == "assign":
emit({"assignees": list(args)})
elif cmd == "add-label-ids":
emit({"labels": json.loads(args[0])})
elif cmd == "issue-meta":
owner, _, name = args[0].partition("/")
emit({"owner": owner, "repo": name, "index": int(args[1])})
else:
die("unknown formatter command: " + cmd)
if __name__ == "__main__":
main(sys.argv)