From 5c377a15e06e004cb76f9128b4c424ef4cd6e3ff Mon Sep 17 00:00:00 2001 From: Calic Date: Wed, 17 Jun 2026 11:47:37 +0000 Subject: [PATCH] Initial: gitea-api (read-only) + sporel-commit tools with Claude skills --- .gitignore | 1 + README.md | 53 +++++++++++++++++ gitea-api.sh | 76 ++++++++++++++++++++++++ skills/gitea-api/SKILL.md | 30 ++++++++++ skills/sporel-commit/SKILL.md | 37 ++++++++++++ sporel-commit.sh | 107 ++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 gitea-api.sh create mode 100644 skills/gitea-api/SKILL.md create mode 100644 skills/sporel-commit/SKILL.md create mode 100755 sporel-commit.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397b4a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..d990eae --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# gitea-devtools + +Small dev tools for the Gitea / Sporel setup. Usable two ways: + +1. **Directly in a shell** (any dev environment) — clone and run the scripts. +2. **As Claude Code skills** — `skills/` are symlinked into `~/.claude/skills/` + and allowlisted, so the recurring privileged chains run without per-step + permission prompts. + +## Tools + +### `gitea-api.sh` — read-only Gitea API helper + +```bash +./gitea-api.sh /orgs/sporel/repos?limit=100 | jq '.[].full_name' +./gitea-api.sh repos/calic/claude-config +``` + +GET requests only — safe to allowlist. Path may omit the leading `/` and the +`api/v1/` prefix. Pretty-prints via `jq` if present. + +### `sporel-commit.sh` — multi-repo commit + push + +```bash +./sporel-commit.sh --dry-run "wip" # preview +./sporel-commit.sh "lib-core: bump manifests to 0.4.0" +``` + +Commits every **dirty** local Sporel repo with one message, using each repo's +**own last-commit author** (Sporel repos have mixed Calic/Axel Meyer authors; +email always `axel.meyer@durania.net`), then pushes if ahead and not diverged. +Diverged repos are reported, never force-pushed. CI fires on tags, not master +pushes, so WIP pushes are safe. + +Pair with [`sporel-sync`](https://git.davoryn.de/calic/claude-config) (in +claude-config) which handles pull/push reconciliation. + +## Configuration (env vars) + +| Var | Default | Used by | +|-----|---------|---------| +| `GITEA_URL` | `http://localhost:3100` | gitea-api.sh — set to `https://git.davoryn.de` off-host | +| `GITEA_TOKEN` | _(auto via docker `gitea` container)_ | gitea-api.sh — set this in dev environments without the container | +| `GITEA_USER` | `calic` | gitea-api.sh | +| `SPOREL_ROOT` | `/root/projects/Sporel` | sporel-commit.sh | + +In dev environments without the local `gitea` container, set `GITEA_URL` and a +read-scope `GITEA_TOKEN`; `gitea-api.sh` then needs no docker access. + +## Self-maintaining + +On failure each script prints the error + a diagnostic hint. If the output was +insufficient to diagnose, improve the script and push this repo. diff --git a/gitea-api.sh b/gitea-api.sh new file mode 100755 index 0000000..3242e9b --- /dev/null +++ b/gitea-api.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# gitea-api.sh — READ-ONLY Gitea API helper. Issues GET requests only. +# +# Usage: gitea-api.sh [curl-args...] +# Example: gitea-api.sh /orgs/sporel/repos +# gitea-api.sh repos/calic/claude-config +# gitea-api.sh /orgs/sporel/repos?limit=100 | jq '.[].full_name' +# +# Path may include or omit a leading slash and the "api/v1/" prefix. +# Output is the raw JSON body (pretty-printed via jq if available). +# +# Auth resolution (first that works): +# 1. $GITEA_TOKEN env var (use this in dev environments) +# 2. temp read-only token via the local `gitea` docker container (auto-cleaned) +# 3. unauthenticated (public endpoints only) +# +# Config: $GITEA_URL (default http://localhost:3100 on the host; +# set to https://git.davoryn.de in dev environments) +# +# This script is SAFE to allowlist: it can only read (GET). It never POSTs, +# PUTs, PATCHes or DELETEs application data. The only writes it performs are +# creating + immediately deleting its own short-lived read-scope token. +# +# Self-maintaining: if a request fails, the error + HTTP code are printed; if +# that was insufficient to diagnose, improve this script's error handling. +set -uo pipefail + +if [ $# -lt 1 ]; then + echo "usage: gitea-api.sh [curl-args...]" >&2 + exit 2 +fi + +PATH_ARG="$1"; shift +GITEA_URL="${GITEA_URL:-http://localhost:3100}" +GITEA_USER="${GITEA_USER:-calic}" + +# normalise path -> api/v1/ +p="${PATH_ARG#/}"; p="${p#api/v1/}" +URL="${GITEA_URL%/}/api/v1/${p}" + +TOKEN="${GITEA_TOKEN:-}" +TMP_TOKEN_NAME="" + +cleanup() { + if [ -n "$TMP_TOKEN_NAME" ]; then + curl -s -X DELETE -u "${GITEA_USER}:${TOKEN}" \ + "${GITEA_URL%/}/api/v1/users/${GITEA_USER}/tokens/${TMP_TOKEN_NAME}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +if [ -z "$TOKEN" ] && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx gitea; then + TMP_TOKEN_NAME="claude-api-$$" + TOKEN=$(docker exec --user git gitea gitea admin user generate-access-token \ + -u "$GITEA_USER" -t "$TMP_TOKEN_NAME" \ + --scopes read:organization,read:repository,read:user,read:issue,read:misc \ + --raw 2>/dev/null) || TOKEN="" + [ -z "$TOKEN" ] && TMP_TOKEN_NAME="" +fi + +AUTH=() +[ -n "$TOKEN" ] && AUTH=(-H "Authorization: token $TOKEN") + +BODY_FILE="$(mktemp)"; trap 'rm -f "$BODY_FILE"; cleanup' EXIT +CODE=$(curl -s -o "$BODY_FILE" -w "%{http_code}" -X GET "${AUTH[@]}" "$URL") + +if [ "$CODE" != "200" ]; then + echo "ERROR: GET $URL -> HTTP $CODE" >&2 + [ -z "$TOKEN" ] && echo "HINT: no token (set GITEA_TOKEN or run where the gitea container is reachable)" >&2 + [ "$CODE" = "401" ] && echo "HINT: 401 — token missing/expired/insufficient scope" >&2 + [ "$CODE" = "404" ] && echo "HINT: 404 — check the path (e.g. orgs//repos, repos//)" >&2 + cat "$BODY_FILE" >&2 + exit 1 +fi + +if command -v jq >/dev/null 2>&1; then jq . < "$BODY_FILE"; else cat "$BODY_FILE"; fi diff --git a/skills/gitea-api/SKILL.md b/skills/gitea-api/SKILL.md new file mode 100644 index 0000000..4d4d57c --- /dev/null +++ b/skills/gitea-api/SKILL.md @@ -0,0 +1,30 @@ +--- +name: gitea-api +description: Read-only Gitea API queries (list org repos, inspect a repo/PR/issue, check CI runs). Use instead of hand-writing curl with tokens. GET-only and allowlisted, so it never prompts. +--- + +# Gitea API (read-only) + +Run for any read query against Gitea instead of constructing `curl` with a token: + +```bash +/root/projects/gitea-devtools/gitea-api.sh | jq +``` + +Path omits the `api/v1/` prefix and an optional leading `/`. Examples: + +| Goal | Command | +|------|---------| +| List org repos | `gitea-api.sh /orgs/sporel/repos?limit=100 \| jq -r '.[].full_name'` | +| Inspect a repo | `gitea-api.sh repos/calic/claude-config` | +| Default branch | `gitea-api.sh repos/sporel/sporel-engine \| jq -r .default_branch` | +| Open PRs | `gitea-api.sh repos///pulls?state=open` | +| Recent CI runs | `gitea-api.sh repos///actions/tasks` | + +It auto-resolves a temp read-scope token via the local `gitea` container (or +uses `$GITEA_TOKEN`). GET-only — it cannot mutate anything, which is why it's +safe to run without prompting. + +On `ERROR: ... HTTP ` read the hint line (401 = token/scope, 404 = path). +If a real query path is missing from this list, just use it — the script +accepts any GET path. diff --git a/skills/sporel-commit/SKILL.md b/skills/sporel-commit/SKILL.md new file mode 100644 index 0000000..0e298af --- /dev/null +++ b/skills/sporel-commit/SKILL.md @@ -0,0 +1,37 @@ +--- +name: sporel-commit +description: Commit + push all dirty local Sporel repos in one shot with a shared message, using each repo's own author. Use after making changes across multiple Sporel repos instead of looping git add/commit/push by hand. +--- + +# Sporel Commit + +Commits every **dirty** local Sporel repo with one message and pushes the safe +ones — replacing the manual `for repo; do cd; git add; git commit; git push` +loop. + +```bash +/root/projects/gitea-devtools/sporel-commit.sh --dry-run "" # ALWAYS preview first +/root/projects/gitea-devtools/sporel-commit.sh "" +``` + +## How to use + +1. **Always `--dry-run` first.** It lists which repos would be committed, how + many files each, and which author it will use. Confirm that matches intent. +2. Run without `--dry-run` to commit + push. + +## Behaviour + +- Author per repo = that repo's **own last-commit author** (Sporel repos are + mixed Calic / Axel Meyer — never assume uniform); email always + `axel.meyer@durania.net`. +- `git add -A` then commit with the shared message. +- Pushes only if the branch is ahead and **not** diverged. Diverged repos are + committed locally but reported for manual rebase — never force-pushed. +- CI triggers on tags (`v0.x.0`), not master pushes, so pushing WIP is safe. + +Exit `0` clean · `2` attention (diverged / no upstream) · `1` error. Address +every ATTENTION line. For tagged releases use `/deploy` instead — this is for +ordinary commits across many repos. + +Complements `/sporel-sync` (pull/push reconciliation, no commit). diff --git a/sporel-commit.sh b/sporel-commit.sh new file mode 100755 index 0000000..ed06656 --- /dev/null +++ b/sporel-commit.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# sporel-commit.sh — Commit + push all DIRTY local Sporel repos with one message. +# +# Usage: sporel-commit.sh [--dry-run] "" +# Example: sporel-commit.sh "lib-core: bump manifests to 0.4.0" +# sporel-commit.sh --dry-run "wip" +# +# For each git repo under $SPOREL_ROOT (recursed, any depth) with uncommitted +# changes, it: +# - picks the author from that repo's OWN last commit (git log -1 %an), +# because Sporel repos have MIXED authors (Calic vs Axel Meyer); +# email is always axel.meyer@durania.net +# - git add -A; git commit -m "" +# - then pushes IF the branch is ahead and not diverged +# +# Push safety: CI pipelines trigger on TAGS (v0.x.0), not on master pushes, +# so committing/pushing WIP to master does not start pipelines. +# +# Diverged repos (behind AND ahead after commit) are reported and left for +# manual rebase — never force-pushed. +# +# Config: $SPOREL_ROOT (default /root/projects/Sporel) +# Exit: 0 all good · 2 attention (diverged/skipped) · 1 hard error +set -uo pipefail + +DRY=false +if [ "${1:-}" = "--dry-run" ]; then DRY=true; shift; fi +MSG="${1:-}" +if [ -z "$MSG" ]; then + echo "usage: sporel-commit.sh [--dry-run] \"\"" >&2 + exit 2 +fi + +ROOT="${SPOREL_ROOT:-/root/projects/Sporel}" +EMAIL="axel.meyer@durania.net" +[ -d "$ROOT" ] || { echo "ERROR: Sporel root not found: $ROOT" >&2; exit 1; } + +declare -a ATTN=() ERRS=() +committed=0; pushed=0; clean=0 + +mapfile -t REPOS < <(find "$ROOT" -maxdepth 6 -name .git -type d 2>/dev/null | sed 's,/\.git$,,' | sort) + +printf '%-50s %s\n' "REPO" "ACTION" +printf '%-50s %s\n' "--------------------------------------------------" "------" + +for repo in "${REPOS[@]}"; do + name="${repo#"$ROOT"/}" + cd "$repo" 2>/dev/null || { printf '%-50s %s\n' "$name" "ERROR cd"; ERRS+=("$name: cd failed"); continue; } + + if [ -z "$(git status --porcelain 2>/dev/null)" ]; then + clean=$((clean+1)); continue # nothing to commit, stay quiet + fi + + branch="$(git branch --show-current 2>/dev/null)" + if [ -z "$branch" ]; then + printf '%-50s %s\n' "$name" "DETACHED (skip)"; ATTN+=("$name: detached HEAD, has changes"); continue + fi + + author="$(git log -1 --pretty=%an 2>/dev/null)"; author="${author:-Calic}" + + if $DRY; then + files="$(git status --porcelain | wc -l | tr -d ' ')" + printf '%-50s %s\n' "$name" "would commit $files file(s) as '$author'" + continue + fi + + if ! cerr="$(git -c user.name="$author" -c user.email="$EMAIL" commit -aqm "$MSG" 2>&1)"; then + printf '%-50s %s\n' "$name" "ERROR commit"; ERRS+=("$name: commit failed: ${cerr:-unknown}"); continue + fi + committed=$((committed+1)) + + # push only if upstream exists, ahead, not diverged + if ! git rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then + printf '%-50s %s\n' "$name" "COMMITTED ($author) — no upstream, NOT pushed" + ATTN+=("$name: committed but no upstream to push to"); continue + fi + git fetch --quiet origin 2>/dev/null || true + counts="$(git rev-list --left-right --count '@{u}...HEAD' 2>/dev/null || echo '0 0')" + behind="${counts%%[[:space:]]*}"; ahead="${counts##*[[:space:]]}" + if [ "${behind:-0}" -gt 0 ]; then + printf '%-50s %s\n' "$name" "COMMITTED ($author) — DIVERGED, NOT pushed" + ATTN+=("$name: committed but diverged (behind $behind) — rebase then push manually"); continue + fi + if perr="$(git push origin "$branch" 2>&1)"; then + printf '%-50s %s\n' "$name" "COMMITTED + PUSHED ($author) up${ahead:-?}" + pushed=$((pushed+1)) + else + printf '%-50s %s\n' "$name" "COMMITTED ($author) — ERROR push" + ERRS+=("$name: push failed: ${perr:-unknown}") + fi +done + +echo +if $DRY; then + echo "Dry-run: ${#REPOS[@]} repos scanned, $clean clean. No changes made." + exit 0 +fi +echo "Summary: ${#REPOS[@]} repos | committed=$committed pushed=$pushed clean=$clean | attention=${#ATTN[@]} errors=${#ERRS[@]}" +[ "${#ATTN[@]}" -gt 0 ] && { echo; echo "ATTENTION:"; printf ' - %s\n' "${ATTN[@]}"; } +[ "${#ERRS[@]}" -gt 0 ] && { echo; echo "ERRORS:"; printf ' - %s\n' "${ERRS[@]}"; } + +if [ "${#ERRS[@]}" -gt 0 ]; then + echo; echo "SELF-MAINTAIN: record errors in this repo's findings, diagnose, improve the script if its output was insufficient." + exit 1 +fi +[ "${#ATTN[@]}" -gt 0 ] && exit 2 +exit 0