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:
Executable
+313
@@ -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
|
||||
Executable
+145
@@ -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)
|
||||
Reference in New Issue
Block a user