Initial: gitea-api (read-only) + sporel-commit tools with Claude skills
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.log
|
||||
53
README.md
Normal file
53
README.md
Normal file
@@ -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.
|
||||
76
gitea-api.sh
Executable file
76
gitea-api.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea-api.sh — READ-ONLY Gitea API helper. Issues GET requests only.
|
||||
#
|
||||
# Usage: gitea-api.sh <path> [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 <path> [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/<path>
|
||||
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/<org>/repos, repos/<owner>/<name>)" >&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
|
||||
30
skills/gitea-api/SKILL.md
Normal file
30
skills/gitea-api/SKILL.md
Normal file
@@ -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 <path> | jq <filter>
|
||||
```
|
||||
|
||||
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/<owner>/<name>/pulls?state=open` |
|
||||
| Recent CI runs | `gitea-api.sh repos/<owner>/<name>/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 <code>` 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.
|
||||
37
skills/sporel-commit/SKILL.md
Normal file
37
skills/sporel-commit/SKILL.md
Normal file
@@ -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 "<msg>" # ALWAYS preview first
|
||||
/root/projects/gitea-devtools/sporel-commit.sh "<msg>"
|
||||
```
|
||||
|
||||
## 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).
|
||||
107
sporel-commit.sh
Executable file
107
sporel-commit.sh
Executable file
@@ -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] "<commit message>"
|
||||
# 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 "<message>"
|
||||
# - 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] \"<commit message>\"" >&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
|
||||
Reference in New Issue
Block a user