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
146 lines
4.3 KiB
Python
Executable File
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)
|