From 1b9b98c424fdb09f770b752711f79bdd6cb93857 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 3 Aug 2026 20:24:34 -0700 Subject: [PATCH] Update gitea utils --- .gitignore | 1 + bin/gitea-issue | 255 ++++++++++++++++++++++++++++++++++++++++++++++++ bin/gitea-pr | 38 +++++++- 3 files changed, 291 insertions(+), 3 deletions(-) create mode 100755 bin/gitea-issue diff --git a/.gitignore b/.gitignore index 228c29f..aab06b0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ existing minimal install_local.sh bin/dotfiles-install +config/dejima config/i3/config.de config/i3/config.local config/gtk-3.0/bookmarks diff --git a/bin/gitea-issue b/bin/gitea-issue new file mode 100755 index 0000000..6f0b432 --- /dev/null +++ b/bin/gitea-issue @@ -0,0 +1,255 @@ +#!/bin/bash +# Read and write Gitea issues from a shell - the issue-side counterpart to +# gitea-pr, and deliberately its twin rather than its sibling: both are +# copied into the sandbox image as single self-contained files, and gitea-pr +# also lives in a dotfiles checkout, so a shared library would be a third +# thing to keep in step across three places. The duplicated config/token/curl +# preamble is pinned in both files by test/static.bats instead. +set -euo pipefail + +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}" + +function usage() { + echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]" + echo " gitea-issue view NUMBER" + echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]" + echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)" + echo " gitea-issue close NUMBER" + echo " gitea-issue reopen NUMBER" + echo "" + echo "--body-file reads the body from FILE, or from stdin when FILE is '-'." + echo "Prefer it over --body for anything with quotes, backticks or newlines." + echo "" + echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \ + "your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \ + "set for this invocation only)." +} + +function require_config() { + if [ -z "${GITEA_SSH_HOST:-}" ]; then + echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST='" \ + "(e.g. gitea.example.com) to ~/.profile.local" >&2 + exit 1 + fi + if [ -z "${GITEA_API:-}" ]; then + echo "GITEA_API is not set. Add 'export GITEA_API=https:///api/v1'" \ + "(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2 + exit 1 + fi +} + +function require_token() { + if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then + GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE") + fi + if [ -z "${GITEA_TOKEN:-}" ]; then + echo "No Gitea token found. Generate one in the Gitea web UI" \ + "(Settings > Applications, scopes write:repository + write:issue)" \ + "and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \ + "in the environment for this invocation only." >&2 + exit 1 + fi +} + +# Feeds the auth header to curl as a config on stdin rather than as an +# argument. Command lines are world-readable through /proc, so `-H +# "Authorization: token $GITEA_TOKEN"` exposes the token to every other +# process on the host for as long as the request runs - and to anything that +# scrapes `ps` output, including the agents this repo exists to sandbox. +# printf is a bash builtin, so the token never lands in another argv. +function gitea_api() { + printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" | + curl -sS -w '\n%{http_code}' -K - "$@" +} + +# Splits gitea_api's combined output into the globals `json` and `http_code` +# and fails with the body on any non-2xx. Every call site did this by hand in +# gitea-pr; there are six of them here, and six hand-rolled copies is where +# one of them quietly stops checking. +function api_or_die() { + local what="$1"; shift + local response + response=$(gitea_api "$@") + http_code=$(echo "$response" | tail -1) + json=$(echo "$response" | sed '$d') + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "Failed to $what (HTTP $http_code):" >&2 + echo "$json" >&2 + exit 1 + fi +} + +# Reads a body from a file, or from stdin for '-'. The point is to keep prose +# out of argv: a body composed on a command line has to survive shell quoting, +# and the things that break it - an apostrophe, a backtick, a $ - are exactly +# what English and code fences are made of. A file (or a heredoc into '-') has +# no such layer. +function read_body_file() { + local file="$1" + if [ "$file" = "-" ]; then + cat + return + fi + if [ ! -f "$file" ]; then + echo "No such body file: $file" >&2 + exit 1 + fi + cat "$file" +} + +function gitea_owner_repo() { + local remote_url host owner_repo + remote_url=$(git remote get-url origin 2>/dev/null) || { + echo "No 'origin' remote found in this repository" >&2 + exit 1 + } + host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#') + if [ "$host" != "$GITEA_SSH_HOST" ]; then + echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-issue only supports this host" >&2 + exit 1 + fi + owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##') + echo "$owner_repo" +} + +function require_number() { + if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then + echo "Expected an issue number, got '${1:-}'" >&2 + exit 1 + fi +} + +# Sets `body` from --body/--body-file, rejecting both. Shared by create and +# comment so the two cannot drift into disagreeing about which one wins. +function parse_body_args() { + body="" + local body_file="" + parsed_title="" + while [ "$#" -gt 0 ]; do + case "$1" in + --title) parsed_title="$2"; shift 2 ;; + --body) body="$2"; shift 2 ;; + --body-file) body_file="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac + done + # Rejected rather than ranked: silently preferring one would make a caller + # that passed both ship a body it didn't mean to. + if [ -n "$body" ] && [ -n "$body_file" ]; then + echo "Pass --body or --body-file, not both" >&2 + exit 1 + fi + if [ -n "$body_file" ]; then + body="$(read_body_file "$body_file")" + fi +} + +if [ "$#" -lt 1 ]; then + usage + exit 1 +fi + +subcommand="$1" +shift + +case "$subcommand" in + list|view|create|comment|close|reopen) + require_config + require_token + owner_repo=$(gitea_owner_repo) + owner="${owner_repo%%/*}" + repo="${owner_repo##*/}" + ;; + *) + usage + exit 1 + ;; +esac + +issues_api="${GITEA_API}/repos/${owner}/${repo}/issues" + +case "$subcommand" in + list) + state="open" + limit="30" + while [ "$#" -gt 0 ]; do + case "$1" in + --state) state="$2"; shift 2 ;; + --limit) limit="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac + done + case "$state" in + open|closed|all) ;; + *) echo "--state must be open, closed or all" >&2; exit 1 ;; + esac + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "--limit must be a number" >&2 + exit 1 + fi + + # type=issues is load-bearing: Gitea's issues endpoint returns pull + # requests too, since a PR *is* an issue with a branch attached. + # Without it every open PR shows up here as an issue, which reads as + # a work list that is quietly twice as long as the real one. + api_or_die "list issues" \ + "${issues_api}?state=${state}&type=issues&limit=${limit}" + echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"' + ;; + view) + require_number "${1:-}" + number="$1" + + api_or_die "fetch issue #${number}" "${issues_api}/${number}" + echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"' + + echo "=== Comments ===" + api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments" + echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"' + ;; + create) + parse_body_args "$@" + if [ -z "$parsed_title" ]; then + echo "gitea-issue create requires --title" >&2 + exit 1 + fi + body="**Filed by LLM agent** + +${body}" + + api_or_die "create issue" -X POST "$issues_api" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg title "$parsed_title" --arg body "$body" \ + '{title: $title, body: $body}')" + echo "$json" | jq -r '.html_url' + ;; + comment) + require_number "${1:-}" + number="$1" + shift + parse_body_args "$@" + if [ -z "$body" ]; then + echo "gitea-issue comment requires --body or --body-file" >&2 + exit 1 + fi + body="**Comment by LLM agent** + +${body}" + + api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg body "$body" '{body: $body}')" + echo "$json" | jq -r '.html_url' + ;; + close|reopen) + require_number "${1:-}" + number="$1" + new_state="closed" + [ "$subcommand" = "reopen" ] && new_state="open" + + api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg state "$new_state" '{state: $state}')" + echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"' + ;; +esac diff --git a/bin/gitea-pr b/bin/gitea-pr index 05987c2..780ff3c 100755 --- a/bin/gitea-pr +++ b/bin/gitea-pr @@ -4,10 +4,13 @@ set -euo pipefail GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}" function usage() { - echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]" + echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]" echo " gitea-pr comments PR_NUMBER" echo " gitea-pr merge PR_NUMBER" echo "" + echo "--body-file reads the body from FILE, or from stdin when FILE is '-'." + echo "Prefer it over --body for anything with quotes, backticks or newlines." + echo "" echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \ "your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \ "set for this invocation only)." @@ -16,12 +19,12 @@ function usage() { function require_config() { if [ -z "${GITEA_SSH_HOST:-}" ]; then echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST='" \ - "(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2 + "(e.g. gitea.example.com) to ~/.profile.local" >&2 exit 1 fi if [ -z "${GITEA_API:-}" ]; then echo "GITEA_API is not set. Add 'export GITEA_API=https:///api/v1'" \ - "(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2 + "(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2 exit 1 fi } @@ -50,6 +53,24 @@ function gitea_api() { curl -sS -w '\n%{http_code}' -K - "$@" } +# Reads a body from a file, or from stdin for '-'. The point is to keep prose +# out of argv: a body composed on a command line has to survive shell quoting, +# and the things that break it - an apostrophe, a backtick, a $ - are exactly +# what English and code fences are made of. A file (or a heredoc into '-') has +# no such layer. +function read_body_file() { + local file="$1" + if [ "$file" = "-" ]; then + cat + return + fi + if [ ! -f "$file" ]; then + echo "No such body file: $file" >&2 + exit 1 + fi + cat "$file" +} + function gitea_owner_repo() { local remote_url host owner_repo remote_url=$(git remote get-url origin 2>/dev/null) || { @@ -91,15 +112,26 @@ case "$subcommand" in create) title="" body="" + body_file="" base="" while [ "$#" -gt 0 ]; do case "$1" in --title) title="$2"; shift 2 ;; --body) body="$2"; shift 2 ;; + --body-file) body_file="$2"; shift 2 ;; --base) base="$2"; shift 2 ;; *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done + # Rejected rather than ranked: silently preferring one would make a + # caller that passed both ship a body it didn't mean to. + if [ -n "$body" ] && [ -n "$body_file" ]; then + echo "Pass --body or --body-file, not both" >&2 + exit 1 + fi + if [ -n "$body_file" ]; then + body="$(read_body_file "$body_file")" + fi body="**Summary generated by LLM agent** ${body}"