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
This commit is contained in:
2026-08-18 16:23:39 -04:00
parent 3c3197df4b
commit ae82fd2233
2 changed files with 458 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env bash
#
# The tracker, from the command line.
#
# Issues are this project's source of truth for what is wanted and what is
# already being worked on, which means "search the tracker" runs 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 — so it is one command here instead of a curl re-derived from
# prose each time. See CLAUDE.md, "Issues".
#
# **Text reaches the API as JSON, never as shell.** An issue body is
# arbitrary prose carrying backticks, quotes and `$`, so bodies are read from
# a file or from stdin and encoded by python3, on the same reasoning that
# keeps release notes out of `gitea-release.sh`'s argument list. Only issue
# numbers and label names cross as arguments, and the numbers are validated.
#
# **Claiming is an assignment, a label and a comment, together.** Any one of
# them alone is a claim somebody else has to go looking for: the assignee is
# what shows in the issue list, `Status/In Progress` is what filters, and the
# comment is what says which branch and what approach. `claim` does all
# three, and refuses outright if somebody else already holds it.
#
# Usage:
# scripts/issue.sh list [--state open|closed|all] [--label L] [--assignee U]
# scripts/issue.sh mine
# scripts/issue.sh search <text...>
# scripts/issue.sh show <n>
# scripts/issue.sh new --title <t> [--labels A,B] [--body-file F]
# scripts/issue.sh claim <n> [--branch <name>] [--body-file F]
# scripts/issue.sh unclaim <n>
# scripts/issue.sh comment <n> [--body-file F]
# scripts/issue.sh close <n> [--body-file F]
# scripts/issue.sh label <n> +Kind/Bug -Status/Blocked
# scripts/issue.sh depends <n> <blocker-n>
# scripts/issue.sh labels
#
# Where a body is taken and no --body-file is given, it is read from stdin.
#
# Environment:
# GITEA_TOKEN a PAT with write:issue (plus write:repository and read:user,
# which the rest of this repo's tooling reaches for)
# GITEA_URL defaults to https://git.ljones.me
# GITEA_REPO defaults to yonlu/yellowjacket
set -euo pipefail
server="${GITEA_URL:-https://git.ljones.me}"
repo="${GITEA_REPO:-yonlu/yellowjacket}"
api="$server/api/v1/repos/$repo"
: "${GITEA_TOKEN:?issue.sh: GITEA_TOKEN is not set}"
command -v python3 >/dev/null || { echo "issue.sh: python3 is required" >&2; exit 1; }
py="$(dirname "$0")/issue_fmt.py"
# ---------------------------------------------------------------- plumbing
call() {
local method="$1" path="$2"
if [ "$method" = GET ]; then
curl -sS -H "Authorization: token $GITEA_TOKEN" "$api$path"
else
curl -sS -X "$method" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- "$api$path"
fi
}
num() {
printf '%s' "${1:-}" | grep -qE '^[0-9]+$' || {
echo "issue.sh: '${1:-}' is not an issue number" >&2
exit 1
}
printf '%s' "$1"
}
# Read a body from a file or stdin. A file of "-" is stdin.
read_body() {
local file="${1:--}"
if [ "$file" = "-" ]; then cat; else cat "$file"; fi
}
me() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" | python3 "$py" login; }
label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; }
# 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 — or a label
# somebody renamed — reports success and does nothing.
add_labels() {
local n="$1" ids
shift
ids="$(call GET "/labels?limit=100" | python3 "$py" label-ids "$(IFS=,; printf '%s' "$*")")"
python3 "$py" add-label-ids "$ids" | call POST "/issues/$n/labels" |
python3 "$py" check >/dev/null
}
drop_label() {
local n="$1" name="$2" id
id="$(label_id "$name" 2>/dev/null)" || return 0
curl -sS -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \
"$api/issues/$n/labels/$id"
}
post_comment() {
local n="$1" text
text="$(cat)"
# Checked here rather than left to the API, which answers an empty body
# with "[Body]: Required" and then this pipeline reports a second, more
# confusing error from the request that was built anyway.
if [ -z "${text//[[:space:]]/}" ]; then
echo "issue.sh: refusing to post an empty comment on #$n" >&2
exit 1
fi
printf '%s\n' "$text" | python3 "$py" wrap-body |
call POST "/issues/$n/comments" | python3 "$py" check >/dev/null
}
# ---------------------------------------------------------------- commands
cmd_list() {
local state=open label="" assignee="" limit=100 q=""
while [ $# -gt 0 ]; do
case "$1" in
--state) state="$2"; shift 2 ;;
--label) label="$2"; shift 2 ;;
--assignee) assignee="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--q) q="$2"; shift 2 ;;
*) echo "issue.sh list: unknown option $1" >&2; exit 1 ;;
esac
done
local path="/issues?type=issues&state=$state&limit=$limit"
[ -n "$label" ] && path="$path&labels=$(python3 "$py" urlquote "$label")"
[ -n "$assignee" ] && path="$path&assigned_by=$assignee"
[ -n "$q" ] && path="$path&q=$(python3 "$py" urlquote "$q")"
call GET "$path" | python3 "$py" list
}
cmd_mine() { cmd_list --assignee "$(me)" "$@"; }
cmd_search() {
[ $# -gt 0 ] || { echo "usage: issue.sh search <text...>" >&2; exit 1; }
echo "-- open --"
cmd_list --state open --q "$*"
echo "-- closed --"
cmd_list --state closed --q "$*"
}
cmd_show() {
local n; n="$(num "${1:-}")"
call GET "/issues/$n" | python3 "$py" show
echo "-- depends on --"
call GET "/issues/$n/dependencies" | python3 "$py" deps
echo "-- comments --"
call GET "/issues/$n/comments" | python3 "$py" comments
}
cmd_new() {
local title="" labels="" file="-"
while [ $# -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--labels) labels="$2"; shift 2 ;;
--body-file) file="$2"; shift 2 ;;
*) echo "issue.sh new: unknown option $1" >&2; exit 1 ;;
esac
done
[ -n "$title" ] || { echo "issue.sh new: --title is required" >&2; exit 1; }
# Label names are resolved to ids first, so a typo is an error here rather
# than an issue filed with a label silently absent.
local ids="[]"
if [ -n "$labels" ]; then
ids="$(call GET "/labels?limit=100" | python3 "$py" label-ids "$labels")"
fi
read_body "$file" | python3 "$py" new-issue "$title" "$ids" |
call POST "/issues" | python3 "$py" created
}
cmd_claim() {
local n; n="$(num "${1:-}")"; shift || true
local branch="" file=""
while [ $# -gt 0 ]; do
case "$1" in
--branch) branch="$2"; shift 2 ;;
--body-file) file="$2"; shift 2 ;;
*) echo "issue.sh claim: unknown option $1" >&2; exit 1 ;;
esac
done
local who holder note
who="$(me)"
holder="$(call GET "/issues/$n" | python3 "$py" assignees)"
# The whole point of the workflow, so it is a hard failure.
if [ -n "$holder" ] && [ "$holder" != "$who" ]; then
echo "issue.sh: #$n is already claimed by $holder — talk to them before starting" >&2
exit 1
fi
# The comment is resolved *before* anything is mutated. Reading it after
# the assignment is how a claim ends up half-made: the assignee and the
# label land, the comment is rejected as empty, and the issue says it is
# taken without saying by what work.
if [ -n "$file" ]; then
note="$(read_body "$file")"
elif [ ! -t 0 ]; then
note="$(read_body -)"
fi
if [ -z "${note//[[:space:]]/}" ]; then
note="Starting work on this${branch:+ on \`$branch\`}."
fi
python3 "$py" assign "$who" | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
add_labels "$n" "Status/In Progress"
printf '%s\n' "$note" | post_comment "$n"
echo "claimed #$n as $who${branch:+ (branch $branch)}"
}
cmd_unclaim() {
local n; n="$(num "${1:-}")"
python3 "$py" assign | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
drop_label "$n" "Status/In Progress"
echo "unclaimed #$n"
}
cmd_comment() {
local n; n="$(num "${1:-}")"; shift || true
local file="-"
[ "${1:-}" = "--body-file" ] && file="$2"
read_body "$file" | post_comment "$n"
echo "commented on #$n"
}
cmd_close() {
local n; n="$(num "${1:-}")"; shift || true
local file=""
[ "${1:-}" = "--body-file" ] && file="$2"
if [ -n "$file" ]; then
read_body "$file" | post_comment "$n"
elif [ ! -t 0 ]; then
read_body - | post_comment "$n"
fi
printf '{"state":"closed"}' | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
# A claim outlives the work if nothing takes the label off.
drop_label "$n" "Status/In Progress"
echo "closed #$n"
}
# Hard blockers are real Gitea dependencies, which render on the issue itself
# — see #73, whose graph is the reason this is not just prose in a comment.
#
# The endpoint takes a whole IssueMeta, not an index: a body of {"index": 88}
# answers **404**, which reads exactly like a missing endpoint on a Gitea
# build that does not have the feature.
cmd_depends() {
local n blocker
n="$(num "${1:-}")"
blocker="$(num "${2:-}")"
python3 "$py" issue-meta "$repo" "$blocker" |
call POST "/issues/$n/dependencies" | python3 "$py" check >/dev/null
echo "#$n now depends on #$blocker"
}
cmd_label() {
local n; n="$(num "${1:-}")"; shift
local add=() del=()
for spec in "$@"; do
case "$spec" in
+*) add+=("${spec#+}") ;;
-*) del+=("${spec#-}") ;;
*) echo "issue.sh label: expected +Name or -Name, got '$spec'" >&2; exit 1 ;;
esac
done
if [ ${#add[@]} -gt 0 ]; then
add_labels "$n" "${add[@]}"
fi
local name
for name in ${del[@]+"${del[@]}"}; do
drop_label "$n" "$name"
done
echo "relabelled #$n"
}
# ---------------------------------------------------------------- dispatch
sub="${1:-}"
[ $# -gt 0 ] && shift
case "$sub" in
list) cmd_list "$@" ;;
mine) cmd_mine "$@" ;;
search) cmd_search "$@" ;;
show) cmd_show "$@" ;;
new) cmd_new "$@" ;;
claim) cmd_claim "$@" ;;
unclaim) cmd_unclaim "$@" ;;
comment) cmd_comment "$@" ;;
close) cmd_close "$@" ;;
label) cmd_label "$@" ;;
depends) cmd_depends "$@" ;;
labels) call GET "/labels?limit=100" | python3 "$py" labels ;;
*)
sed -n '/^# Usage:/,/^# Environment:/p' "$0" | sed 's/^# \{0,1\}//'
exit 1
;;
esac
+145
View File
@@ -0,0 +1,145 @@
#!/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)