import: contenido inicial de la skill gitea
This commit is contained in:
120
scripts/actions-list-runs.sh
Normal file
120
scripts/actions-list-runs.sh
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — listar runs de Gitea Actions de un repo.
|
||||
#
|
||||
# Gitea 1.24 expone `/actions/tasks` (NO `/actions/runs`). El endpoint solo
|
||||
# acepta `page` y `limit` — los demás filtros (workflow, branch, status,
|
||||
# event, actor) se aplican client-side acá.
|
||||
#
|
||||
# Uso:
|
||||
# actions-list-runs.sh <owner>/<repo> [--workflow <filename>]
|
||||
# [--branch <ref>] [--status <s>]
|
||||
# [--event <e>] [--actor <user>]
|
||||
# [--limit N]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
repo_arg=""
|
||||
workflow=""
|
||||
branch=""
|
||||
status=""
|
||||
event=""
|
||||
limit=10
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--workflow) workflow="$2"; shift 2 ;;
|
||||
--branch) branch="$2"; shift 2 ;;
|
||||
--status) status="$2"; shift 2 ;;
|
||||
--event) event="$2"; shift 2 ;;
|
||||
--limit) limit="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
Uso: actions-list-runs.sh <owner>/<repo> [opciones]
|
||||
|
||||
Opciones (todas client-side, el server solo respeta page+limit):
|
||||
--workflow <filename> ej. deploy-infra.yml (matchea workflow_id)
|
||||
--branch <ref> ej. main (matchea head_branch)
|
||||
--status <s> success | failure | running | waiting | cancelled
|
||||
--event <e> push | pull_request | workflow_dispatch
|
||||
--limit N cuántos traer del server (default 10)
|
||||
|
||||
NOTA: Gitea no devuelve el actor del run en /actions/tasks, así que no hay
|
||||
filtro --actor. Si necesitás esa info, mirá el merge commit con git log.
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
-*) echo "ERROR: flag desconocida: $1" >&2; exit 2 ;;
|
||||
*) repo_arg="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$repo_arg" ]]; then
|
||||
echo "ERROR: pasá <owner>/<repo>." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
resp="$("$QUERY" "/repos/${owner}/${repo}/actions/tasks?limit=${limit}")"
|
||||
|
||||
# export ANTES del pipe — el VAR=val inline no llega al python downstream.
|
||||
export PY_WORKFLOW="$workflow" PY_BRANCH="$branch" PY_STATUS="$status" \
|
||||
PY_EVENT="$event" PY_OWNER="$owner" PY_REPO="$repo"
|
||||
|
||||
echo "$resp" | PYTHONIOENCODING=utf-8 python -c '
|
||||
import json, os, sys
|
||||
d = json.load(sys.stdin)
|
||||
runs = d.get("workflow_runs", [])
|
||||
total = d.get("total_count", len(runs))
|
||||
|
||||
owner = os.environ.get("PY_OWNER", "")
|
||||
repo = os.environ.get("PY_REPO", "")
|
||||
|
||||
def keep(r):
|
||||
wf = os.environ.get("PY_WORKFLOW")
|
||||
if wf:
|
||||
rwf = r.get("workflow_id","")
|
||||
if rwf != wf and not rwf.endswith(wf):
|
||||
return False
|
||||
if os.environ.get("PY_BRANCH") and r.get("head_branch") != os.environ["PY_BRANCH"]:
|
||||
return False
|
||||
if os.environ.get("PY_STATUS") and r.get("status") != os.environ["PY_STATUS"]:
|
||||
return False
|
||||
if os.environ.get("PY_EVENT") and r.get("event") != os.environ["PY_EVENT"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
filtered = [r for r in runs if keep(r)]
|
||||
any_filter = any(os.environ.get(k) for k in ("PY_WORKFLOW","PY_BRANCH","PY_STATUS","PY_EVENT"))
|
||||
filt_label = f" (filtered: {len(filtered)}/{len(runs)})" if any_filter else ""
|
||||
print(f"{len(filtered)} run(s) en {owner}/{repo}{filt_label}, total disponible={total}")
|
||||
print()
|
||||
|
||||
if not filtered:
|
||||
sys.exit(0)
|
||||
|
||||
for r in filtered:
|
||||
tid = r.get("id")
|
||||
rnum = r.get("run_number")
|
||||
title = r.get("display_title") or r.get("name","?")
|
||||
if len(title) > 70: title = title[:67] + "..."
|
||||
st = r.get("status","?")
|
||||
branch = r.get("head_branch") or "?"
|
||||
ev = r.get("event","?")
|
||||
wf = r.get("workflow_id","?")
|
||||
created = (r.get("created_at") or "")[:19].replace("T"," ")
|
||||
sha = (r.get("head_sha") or "")[:7]
|
||||
print(f" #{rnum:<4} task={tid:<5} {st:<10} {ev:<18} {branch:<14} {wf}")
|
||||
print(f" {title}")
|
||||
print(f" sha={sha} created={created}")
|
||||
'
|
||||
287
scripts/actions-logs.sh
Normal file
287
scripts/actions-logs.sh
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — leer logs de un run de Actions con filtros precisos.
|
||||
#
|
||||
# Diseñado para no saturar la ventana de contexto de Claude. Nunca devuelve
|
||||
# un log entero por default. Modos:
|
||||
#
|
||||
# actions-logs.sh <repo> <run_ref> # resumen (auto-tail si falló)
|
||||
# actions-logs.sh <repo> <run_ref> --tail N
|
||||
# actions-logs.sh <repo> <run_ref> --head N
|
||||
# actions-logs.sh <repo> <run_ref> --lines X-Y
|
||||
# actions-logs.sh <repo> <run_ref> --grep PAT [-C N]
|
||||
# actions-logs.sh <repo> <run_ref> --errors [-C N]
|
||||
# actions-logs.sh <repo> <run_ref> --count
|
||||
# actions-logs.sh <repo> <run_ref> --save /tmp/log.txt
|
||||
# actions-logs.sh <repo> <run_ref> --full [--i-mean-it]
|
||||
# actions-logs.sh <repo> <run_ref> --job <job_id> ... # skip probe
|
||||
#
|
||||
# <run_ref> puede ser run_number (lo que el usuario ve, ej. 11) o task_id
|
||||
# (numérico interno). El script matchea por cualquiera de los dos.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
repo_arg=""
|
||||
run_ref=""
|
||||
job_id=""
|
||||
mode="auto"
|
||||
tail_n=""
|
||||
head_n=""
|
||||
lines_range=""
|
||||
grep_pat=""
|
||||
context_n="0"
|
||||
grep_case_sensitive=0
|
||||
save_path=""
|
||||
i_mean_it=0
|
||||
raw_output=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--job) job_id="$2"; shift 2 ;;
|
||||
--tail) tail_n="$2"; mode="tail"; shift 2 ;;
|
||||
--head) head_n="$2"; mode="head"; shift 2 ;;
|
||||
--lines) lines_range="$2"; mode="lines"; shift 2 ;;
|
||||
--grep) grep_pat="$2"; mode="grep"; shift 2 ;;
|
||||
--grep-case) grep_case_sensitive=1; shift ;;
|
||||
--errors) grep_pat='(error|fail|fatal|exception|panic|unhealthy|denied|refused|cannot|not found|missing)'; mode="grep"; shift ;;
|
||||
--context|-C) context_n="$2"; shift 2 ;;
|
||||
--count) mode="count"; shift ;;
|
||||
--save) save_path="$2"; mode="save"; shift 2 ;;
|
||||
--full) mode="full"; shift ;;
|
||||
--i-mean-it) i_mean_it=1; shift ;;
|
||||
--raw) raw_output=1; shift ;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
Uso: actions-logs.sh <owner>/<repo> <run_ref> [opciones]
|
||||
|
||||
run_ref: run_number (visible en URL) o task_id (numérico interno).
|
||||
|
||||
Modos (default: auto-summary basado en status del run):
|
||||
--tail N últimas N líneas
|
||||
--head N primeras N líneas
|
||||
--lines X-Y rango (1-indexed inclusive)
|
||||
--grep PAT [-C N] regex case-insensitive con N líneas de contexto
|
||||
--grep-case con --grep, hacer case-sensitive
|
||||
--errors [-C N] shortcut: regex de errores comunes
|
||||
--count solo número de líneas
|
||||
--save PATH escribir log entero a archivo (no a stdout, sin sanitizar)
|
||||
--full volcado completo (aborta si > 1000 lineas)
|
||||
--i-mean-it con --full sobre log grande, confirma intención
|
||||
--job JOB_ID usar este job_id directo (saltea el probe)
|
||||
--raw no sanitizar (default: stripear ANSI escapes y truncar
|
||||
líneas > 500 chars; necesario porque docker stack deploy
|
||||
vuelca progress bars con mucho \\x1b[2K)
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
-*) echo "ERROR: flag desconocida: $1" >&2; exit 2 ;;
|
||||
*)
|
||||
if [[ -z "$repo_arg" ]]; then repo_arg="$1"
|
||||
elif [[ -z "$run_ref" ]]; then run_ref="$1"
|
||||
else echo "ERROR: argumento extra: $1" >&2; exit 2
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$repo_arg" || -z "$run_ref" ]]; then
|
||||
echo "ERROR: pasá <owner>/<repo> <run_ref>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
# ─── Resolver task_id y status ──────────────────────────────────────────
|
||||
resp="$("$QUERY" "/repos/${owner}/${repo}/actions/tasks?limit=50")"
|
||||
|
||||
read task_id task_status <<< "$(echo "$resp" | PY_REF="$run_ref" python -c '
|
||||
import json, os, sys
|
||||
ref = int(os.environ["PY_REF"])
|
||||
d = json.load(sys.stdin)
|
||||
for r in d.get("workflow_runs", []):
|
||||
if r.get("id") == ref or r.get("run_number") == ref:
|
||||
print(r.get("id"), r.get("status"))
|
||||
sys.exit(0)
|
||||
' | tr -d '\r')"
|
||||
|
||||
if [[ -z "${task_id:-}" ]]; then
|
||||
echo "ERROR: no encontré ningún run con id o run_number = $run_ref" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# ─── Resolver job_id (probe si no se pasó) ──────────────────────────────
|
||||
probe_job_id() {
|
||||
local target_task="$1"
|
||||
local found=""
|
||||
set +o pipefail
|
||||
for delta in 0 1 2 3 4 5 -1 -2 -3 6 7 8 9 10; do
|
||||
local jid=$((target_task + delta))
|
||||
if [[ $jid -lt 1 ]]; then continue; fi
|
||||
local first
|
||||
first="$("$QUERY" "/repos/${owner}/${repo}/actions/jobs/${jid}/logs" 2>/dev/null | head -1 || true)"
|
||||
if echo "$first" | grep -q "received task ${target_task} "; then
|
||||
found="$jid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
set -o pipefail
|
||||
printf '%s' "$found"
|
||||
}
|
||||
|
||||
if [[ -z "$job_id" ]]; then
|
||||
echo "→ Probing job_id para task $task_id..." >&2
|
||||
job_id="$(probe_job_id "$task_id")"
|
||||
if [[ -z "$job_id" ]]; then
|
||||
echo "ERROR: no pude resolver job_id (probé task_id±10)." >&2
|
||||
echo " Probá con --job <jid> directo. Listá jobs candidatos con:" >&2
|
||||
echo " for j in \$(seq <range>); do query.sh /repos/$owner/$repo/actions/jobs/\$j/logs | head -1; done" >&2
|
||||
exit 4
|
||||
fi
|
||||
echo "→ job_id resuelto: $job_id" >&2
|
||||
fi
|
||||
|
||||
LOG_PATH="/repos/${owner}/${repo}/actions/jobs/${job_id}/logs"
|
||||
|
||||
# ─── Helper: ejecutar la query y devolver el log a stdout o a temp file ─
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
LOG_FILE="$TMP_DIR/log.txt"
|
||||
|
||||
fetch_log() {
|
||||
"$QUERY" "$LOG_PATH" > "$LOG_FILE"
|
||||
if [[ "$raw_output" -eq 0 ]]; then
|
||||
# Sanitize: strip ANSI escape sequences (CSI, OSC, simple ESC[...m, etc.)
|
||||
# y truncar líneas > 500 chars (docker stack deploy las hace de 30KB con
|
||||
# progress bars). El reemplazo lo hace Python para encoding-safe.
|
||||
PY_LOG_IN="$LOG_FILE" python -c '
|
||||
import re, os
|
||||
ansi = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-_]")
|
||||
maxlen = 500
|
||||
fp_in = os.environ["PY_LOG_IN"]
|
||||
with open(fp_in, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
with open(fp_in, "w", encoding="utf-8") as f:
|
||||
for line in lines:
|
||||
line = ansi.sub("", line.rstrip("\n"))
|
||||
if len(line) > maxlen:
|
||||
line = line[:maxlen] + f"... [truncated, +{len(line)-maxlen} chars]"
|
||||
f.write(line + "\n")
|
||||
'
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Modo: count ────────────────────────────────────────────────────────
|
||||
if [[ "$mode" == "count" ]]; then
|
||||
fetch_log
|
||||
wc -l < "$LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Modo: save (siempre RAW, sin sanitizar — preserva el log original) ─
|
||||
if [[ "$mode" == "save" ]]; then
|
||||
"$QUERY" "$LOG_PATH" > "$save_path"
|
||||
size="$(wc -c < "$save_path")"
|
||||
lines="$(wc -l < "$save_path")"
|
||||
echo "→ guardado en $save_path ($lines líneas, $size bytes, job_id=$job_id)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Modo: full (con guardrail) ─────────────────────────────────────────
|
||||
if [[ "$mode" == "full" ]]; then
|
||||
fetch_log
|
||||
total="$(wc -l < "$LOG_FILE")"
|
||||
if [[ "$total" -gt 1000 && "$i_mean_it" -ne 1 ]]; then
|
||||
echo "ERROR: el log tiene $total líneas (> 1000). Volcar entero te llena la" >&2
|
||||
echo " ventana. Si REALMENTE querés todo, agregá --i-mean-it" >&2
|
||||
echo " o usá --tail/--head/--grep/--save." >&2
|
||||
exit 5
|
||||
fi
|
||||
cat "$LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Modo: tail / head / lines / grep ──────────────────────────────────
|
||||
if [[ "$mode" == "tail" ]]; then
|
||||
fetch_log
|
||||
tail -n "$tail_n" "$LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "head" ]]; then
|
||||
fetch_log
|
||||
head -n "$head_n" "$LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "lines" ]]; then
|
||||
fetch_log
|
||||
start="${lines_range%-*}"
|
||||
end="${lines_range#*-}"
|
||||
if [[ -z "$start" || -z "$end" ]]; then
|
||||
echo "ERROR: --lines necesita formato X-Y (ej. 100-150)." >&2
|
||||
exit 2
|
||||
fi
|
||||
sed -n "${start},${end}p" "$LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "grep" ]]; then
|
||||
fetch_log
|
||||
flags="-nE"
|
||||
[[ "$grep_case_sensitive" -eq 0 ]] && flags="${flags}i"
|
||||
if [[ "$context_n" != "0" ]]; then
|
||||
flags="$flags -C $context_n"
|
||||
fi
|
||||
# No usar set -e short-circuit si grep no matchea
|
||||
set +e
|
||||
grep $flags "$grep_pat" "$LOG_FILE"
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 1 ]]; then
|
||||
echo "(sin matches para: $grep_pat)" >&2
|
||||
elif [[ $rc -ne 0 ]]; then
|
||||
exit $rc
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Modo: auto (default) ───────────────────────────────────────────────
|
||||
# Resumen inteligente:
|
||||
# - run failed/cancelled → tail 40 + header
|
||||
# - run success → header solo + último step (tail 5)
|
||||
# - run in_progress → tail 20
|
||||
#
|
||||
# task_status posibles en Gitea: success, failure, running, waiting, cancelled,
|
||||
# blocked, skipped, unknown.
|
||||
fetch_log
|
||||
total="$(wc -l < "$LOG_FILE")"
|
||||
|
||||
case "$task_status" in
|
||||
failure|cancelled|skipped)
|
||||
echo "─── job $job_id status=$task_status ($total líneas total) ───"
|
||||
echo "─── últimas 40 líneas ───"
|
||||
tail -n 40 "$LOG_FILE"
|
||||
;;
|
||||
running|waiting|blocked)
|
||||
echo "─── job $job_id status=$task_status ($total líneas hasta ahora) ───"
|
||||
echo "─── últimas 20 líneas (en progreso) ───"
|
||||
tail -n 20 "$LOG_FILE"
|
||||
;;
|
||||
success|*)
|
||||
echo "─── job $job_id status=$task_status ($total líneas total) ───"
|
||||
echo "─── últimas 5 líneas (resumen) ───"
|
||||
tail -n 5 "$LOG_FILE"
|
||||
echo
|
||||
echo "Para ver más: --tail N | --grep PAT | --errors | --lines X-Y | --full"
|
||||
;;
|
||||
esac
|
||||
108
scripts/actions-view.sh
Normal file
108
scripts/actions-view.sh
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — ver detalle de un run de Actions.
|
||||
#
|
||||
# Gitea no tiene `GET /actions/runs/{id}` ni `/actions/runs/{id}/jobs`, así que
|
||||
# este script:
|
||||
# 1. Lista /actions/tasks y matchea por task_id o run_number
|
||||
# 2. Imprime la metadata
|
||||
# 3. Hace un probe del job_id (porque la API tampoco lo expone) leyendo la
|
||||
# primera línea de los logs candidatos hasta encontrar el que diga
|
||||
# "received task <task_id>"
|
||||
#
|
||||
# Uso:
|
||||
# actions-view.sh <owner>/<repo> <run_number|task_id>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
cat >&2 <<EOF
|
||||
Uso: actions-view.sh <owner>/<repo> <run_number|task_id>
|
||||
Ejemplo: actions-view.sh NucleOS/nucleo-infra 11
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_arg="$1"
|
||||
run_ref="$2"
|
||||
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
resp="$("$QUERY" "/repos/${owner}/${repo}/actions/tasks?limit=50")"
|
||||
|
||||
# Matchear por task.id o run_number
|
||||
task_data="$(echo "$resp" | PY_REF="$run_ref" python -c '
|
||||
import json, os, sys
|
||||
ref = int(os.environ["PY_REF"])
|
||||
d = json.load(sys.stdin)
|
||||
for r in d.get("workflow_runs", []):
|
||||
if r.get("id") == ref or r.get("run_number") == ref:
|
||||
print(json.dumps(r))
|
||||
break
|
||||
' | tr -d '\r')"
|
||||
|
||||
if [[ -z "$task_data" ]]; then
|
||||
echo "ERROR: no encontré ningún run con id o run_number = $run_ref en los últimos 50." >&2
|
||||
echo " Probá: actions-list-runs.sh $owner/$repo --limit 50" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
task_id="$(echo "$task_data" | python -c 'import json,sys; print(json.load(sys.stdin)["id"])' | tr -d '\r')"
|
||||
|
||||
export PY_OWNER="$owner" PY_REPO="$repo" PYTHONIOENCODING=utf-8
|
||||
echo "$task_data" | python -c '
|
||||
import json, sys
|
||||
r = json.load(sys.stdin)
|
||||
fmt = lambda s, n: (s or "")[:n].replace("T", " ")
|
||||
print("=== Run #{} (task_id={}) ===".format(r.get("run_number"), r.get("id")))
|
||||
print(" workflow: " + str(r.get("workflow_id")))
|
||||
print(" status: " + str(r.get("status")))
|
||||
print(" event: " + str(r.get("event")))
|
||||
print(" branch: " + str(r.get("head_branch")))
|
||||
print(" sha: " + fmt(r.get("head_sha"), 12))
|
||||
print(" title: " + str(r.get("display_title")))
|
||||
print(" created: " + fmt(r.get("created_at"), 19))
|
||||
print(" started: " + fmt(r.get("run_started_at"), 19))
|
||||
print(" updated: " + fmt(r.get("updated_at"), 19))
|
||||
print(" url: " + str(r.get("url")))
|
||||
'
|
||||
echo
|
||||
|
||||
# ─── Probe job_id ───────────────────────────────────────────────────────
|
||||
# Gitea no expone una API para listar jobs de un run. Pero la primera línea
|
||||
# de cada log contiene "received task <task_id>". Probamos un rango pequeño
|
||||
# y matcheamos.
|
||||
echo "→ Probing job_id (Gitea no tiene API para listar jobs de un run)..." >&2
|
||||
|
||||
found_jid=""
|
||||
# Disable pipefail dentro del probe: `head -1` cierra el pipe upstream y curl
|
||||
# muere con SIGPIPE (141) — con pipefail, eso mata el script.
|
||||
set +o pipefail
|
||||
for delta in 0 1 2 3 4 5 -1 -2 -3 6 7 8 9 10; do
|
||||
jid=$((task_id + delta))
|
||||
if [[ $jid -lt 1 ]]; then continue; fi
|
||||
first_line="$("$QUERY" "/repos/${owner}/${repo}/actions/jobs/${jid}/logs" 2>/dev/null | head -1 || true)"
|
||||
if echo "$first_line" | grep -q "received task ${task_id} "; then
|
||||
found_jid="$jid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
set -o pipefail
|
||||
|
||||
if [[ -n "$found_jid" ]]; then
|
||||
echo " job_id (probed): $found_jid"
|
||||
echo " para logs: bash actions-logs.sh $owner/$repo $task_id [--tail N | --errors | ...]"
|
||||
else
|
||||
echo " ⚠️ No pude encontrar el job_id en task_id±10. El run podría tener" >&2
|
||||
echo " múltiples jobs o estar muy desfasado. Probá rangos más anchos" >&2
|
||||
echo " manualmente: query.sh /repos/$owner/$repo/actions/jobs/<jid>/logs" >&2
|
||||
fi
|
||||
57
scripts/pr-comments.sh
Normal file
57
scripts/pr-comments.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — leer comentarios de un PR.
|
||||
#
|
||||
# Uso:
|
||||
# pr-comments.sh <owner>/<repo> <number>
|
||||
# pr-comments.sh <repo> <number>
|
||||
#
|
||||
# Los PRs son issues con un flag "pull_request" en Gitea — los comentarios viven
|
||||
# en /issues/{n}/comments, no en /pulls/{n}/comments. Para review-comments
|
||||
# (sobre líneas de código) ver query.sh /repos/.../pulls/{n}/reviews.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
cat >&2 <<EOF
|
||||
Uso: pr-comments.sh <owner>/<repo> <number>
|
||||
pr-comments.sh <repo> <number>
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_arg="$1"
|
||||
number="$2"
|
||||
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
resp="$("$QUERY" "/repos/${owner}/${repo}/issues/${number}/comments")"
|
||||
|
||||
if [[ "${resp:0:1}" != "[" ]]; then
|
||||
echo "ERROR: respuesta inesperada:" >&2
|
||||
echo "$resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$resp" | PYTHONIOENCODING=utf-8 python -c "
|
||||
import json, sys
|
||||
comments = json.load(sys.stdin)
|
||||
print(f'{len(comments)} comentario(s) en PR #$number ($owner/$repo):')
|
||||
print()
|
||||
for c in comments:
|
||||
user = (c.get('user') or {}).get('login','?')
|
||||
created = (c.get('created_at') or '')[:19].replace('T',' ')
|
||||
body = (c.get('body') or '').strip()
|
||||
print(f'─── {user} @ {created} ───')
|
||||
print(body)
|
||||
print()
|
||||
"
|
||||
163
scripts/pr-create.sh
Normal file
163
scripts/pr-create.sh
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — crear un PR.
|
||||
#
|
||||
# Uso:
|
||||
# pr-create.sh <owner>/<repo> --head <branch> [--base main] --title "..." \
|
||||
# (--body "..." | --body-file <path>) [--draft]
|
||||
#
|
||||
# Garantías:
|
||||
# - Body en UTF-8 puro (vía Python json.dumps + curl --data-binary @file)
|
||||
# - Anti-AI-attribution guard sobre title + body (rechaza si encuentra
|
||||
# "Co-Authored-By: Claude", "🤖", "Generated with Claude", "Anthropic" en
|
||||
# contexto de atribución)
|
||||
# - Trap EXIT borra el temp dir con el body al terminar
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
repo_arg=""
|
||||
head=""
|
||||
base="main"
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
draft=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--head) head="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--draft) draft=1; shift ;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
Uso: pr-create.sh <owner>/<repo> --head <branch> [--base main] \\
|
||||
--title "..." (--body "..." | --body-file <path>) [--draft]
|
||||
|
||||
Ejemplos:
|
||||
pr-create.sh NucleOS/nucleo-infra --head fix/foo --title "fix(x): ..." \\
|
||||
--body-file /tmp/pr-body.md
|
||||
|
||||
pr-create.sh nucleo-infra --head feat/y --title "..." --body "linea 1"
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
-*) echo "ERROR: flag desconocida: $1" >&2; exit 2 ;;
|
||||
*) repo_arg="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$repo_arg" || -z "$head" || -z "$title" ]]; then
|
||||
echo "ERROR: faltan args. Necesitás: <owner>/<repo> --head <branch> --title \"...\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolver owner/repo
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
# Cargar body desde archivo si corresponde
|
||||
if [[ -n "$body_file" ]]; then
|
||||
if [[ -n "$body" ]]; then
|
||||
echo "ERROR: pasá --body o --body-file, no ambos." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$body_file" ]]; then
|
||||
echo "ERROR: --body-file '$body_file' no existe." >&2
|
||||
exit 2
|
||||
fi
|
||||
body="$(cat "$body_file")"
|
||||
fi
|
||||
|
||||
# ─── Anti-AI-attribution guard ──────────────────────────────────────────
|
||||
# Regla dura. Ver memoria feedback_no_ai_attribution.md.
|
||||
combined="$title
|
||||
$body"
|
||||
|
||||
ai_patterns=(
|
||||
"Co-Authored-By:[[:space:]]*Claude"
|
||||
"Co-Authored-By:[[:space:]]*Anthropic"
|
||||
"🤖"
|
||||
"Generated with[[:space:]]*\[?Claude"
|
||||
"Generated with[[:space:]]*Anthropic"
|
||||
"Created with[[:space:]]*\[?Claude"
|
||||
"Powered by[[:space:]]*Claude"
|
||||
"Made with[[:space:]]*Claude"
|
||||
)
|
||||
|
||||
for pat in "${ai_patterns[@]}"; do
|
||||
if echo "$combined" | grep -iqE "$pat"; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: el title o body contiene marker de AI attribution:
|
||||
pattern: $pat
|
||||
|
||||
Esta skill bloquea esos markers (regla dura, ver memoria
|
||||
feedback_no_ai_attribution.md). El usuario considera deshonesto darle crédito
|
||||
a Claude/Anthropic por trabajo del usuario. Removelos y volvé a correr.
|
||||
|
||||
Líneas que matchean:
|
||||
EOF
|
||||
echo "$combined" | grep -inE "$pat" | head -5 >&2
|
||||
exit 4
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── Build body JSON con Python (UTF-8 safe) ────────────────────────────
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
BODY_JSON="$TMP_DIR/body.json"
|
||||
|
||||
PY_TITLE="$title" PY_BODY="$body" PY_HEAD="$head" PY_BASE="$base" PY_DRAFT="$draft" \
|
||||
python -c '
|
||||
import json, os
|
||||
data = {
|
||||
"title": os.environ["PY_TITLE"],
|
||||
"body": os.environ["PY_BODY"],
|
||||
"head": os.environ["PY_HEAD"],
|
||||
"base": os.environ["PY_BASE"],
|
||||
}
|
||||
if os.environ.get("PY_DRAFT") == "1":
|
||||
# Gitea no tiene draft API field, pero podés prefijar el title
|
||||
data["title"] = "[WIP] " + data["title"]
|
||||
# json.dumps con ensure_ascii=True (default) escapa todo non-ASCII a \uXXXX,
|
||||
# eliminando cualquier riesgo de encoding raro en transit.
|
||||
print(json.dumps(data))
|
||||
' > "$BODY_JSON"
|
||||
|
||||
# Sanity check
|
||||
if [[ ! -s "$BODY_JSON" ]]; then
|
||||
echo "ERROR: body JSON vacío después de python. Bug del script." >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
echo "→ POST /repos/${owner}/${repo}/pulls" >&2
|
||||
echo " head: $head → base: $base" >&2
|
||||
echo " title: $title" >&2
|
||||
echo " body: ${#body} chars" >&2
|
||||
|
||||
resp="$("$QUERY" -X POST \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @"$BODY_JSON" \
|
||||
"/repos/${owner}/${repo}/pulls")"
|
||||
|
||||
echo "$resp" | python -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
if d.get('number'):
|
||||
print(f'✓ PR #{d[\"number\"]} creado')
|
||||
print(f' url: {d.get(\"html_url\")}')
|
||||
else:
|
||||
print('ERROR:', d.get('message') or d, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
"
|
||||
84
scripts/pr-list.sh
Normal file
84
scripts/pr-list.sh
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — listar PRs de un repo.
|
||||
#
|
||||
# Uso:
|
||||
# pr-list.sh <owner>/<repo> [--state open|closed|all] [--limit N]
|
||||
#
|
||||
# Si pasás solo <repo>, usa GITEA_DEFAULT_OWNER del .env (NucleOS).
|
||||
# Devuelve una tabla compacta. Para ver el JSON crudo, usá query.sh directamente.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
state="open"
|
||||
limit=20
|
||||
repo_arg=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--state) state="$2"; shift 2 ;;
|
||||
--limit) limit="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
Uso: pr-list.sh <owner>/<repo> [--state open|closed|all] [--limit N]
|
||||
|
||||
Ejemplos:
|
||||
pr-list.sh NucleOS/nucleo-infra
|
||||
pr-list.sh nucleo-infra --state closed --limit 5
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
-*) echo "ERROR: flag desconocida: $1" >&2; exit 2 ;;
|
||||
*) repo_arg="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$repo_arg" ]]; then
|
||||
echo "ERROR: pasá <owner>/<repo> o solo <repo>." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolver owner/repo
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
resp="$("$QUERY" "/repos/${owner}/${repo}/pulls?state=${state}&limit=${limit}")"
|
||||
|
||||
# Detectar errores (objeto, no array)
|
||||
if [[ "${resp:0:1}" == "{" ]]; then
|
||||
echo "$resp" | PYTHONIOENCODING=utf-8 python -c "
|
||||
import json, sys
|
||||
d=json.load(sys.stdin)
|
||||
print('ERROR:', d.get('message') or d.get('error') or d, file=sys.stderr)
|
||||
" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$resp" | PYTHONIOENCODING=utf-8 python -c "
|
||||
import json, sys
|
||||
prs = json.load(sys.stdin)
|
||||
if not prs:
|
||||
print('(sin PRs en estado: $state)')
|
||||
sys.exit(0)
|
||||
print(f'{len(prs)} PR(s) en $owner/$repo (state=$state):')
|
||||
print()
|
||||
for pr in prs:
|
||||
n = pr.get('number')
|
||||
title = (pr.get('title') or '').replace('\n', ' ')
|
||||
if len(title) > 70: title = title[:67] + '...'
|
||||
state_label = 'merged' if pr.get('merged') else pr.get('state','?')
|
||||
head = (pr.get('head') or {}).get('ref','?')
|
||||
base = (pr.get('base') or {}).get('ref','?')
|
||||
user = (pr.get('user') or {}).get('login','?')
|
||||
updated = (pr.get('updated_at') or '')[:10]
|
||||
print(f' #{n:<4} [{state_label:<7}] {title}')
|
||||
print(f' {user} | {head} → {base} | updated {updated}')
|
||||
"
|
||||
79
scripts/pr-view.sh
Normal file
79
scripts/pr-view.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — ver detalle de un PR.
|
||||
#
|
||||
# Uso:
|
||||
# pr-view.sh <owner>/<repo> <number>
|
||||
# pr-view.sh <repo> <number> # usa GITEA_DEFAULT_OWNER
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
cat >&2 <<EOF
|
||||
Uso: pr-view.sh <owner>/<repo> <number>
|
||||
pr-view.sh <repo> <number>
|
||||
|
||||
Ejemplos:
|
||||
pr-view.sh NucleOS/nucleo-infra 14
|
||||
pr-view.sh nucleo-infra 14
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_arg="$1"
|
||||
number="$2"
|
||||
|
||||
if [[ "$repo_arg" == */* ]]; then
|
||||
owner="${repo_arg%%/*}"
|
||||
repo="${repo_arg##*/}"
|
||||
else
|
||||
set -a; source "$SKILL_DIR/.env"; set +a
|
||||
owner="${GITEA_DEFAULT_OWNER:?owner no especificado y GITEA_DEFAULT_OWNER vacío}"
|
||||
repo="$repo_arg"
|
||||
fi
|
||||
|
||||
resp="$("$QUERY" "/repos/${owner}/${repo}/pulls/${number}")"
|
||||
|
||||
# Si no es JSON-object o tiene error
|
||||
if [[ "${resp:0:1}" != "{" ]]; then
|
||||
echo "ERROR: respuesta inesperada:" >&2
|
||||
echo "$resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$resp" | PYTHONIOENCODING=utf-8 python -c "
|
||||
import json, sys
|
||||
pr = json.load(sys.stdin)
|
||||
if pr.get('message') and not pr.get('number'):
|
||||
print('ERROR:', pr.get('message'), file=sys.stderr); sys.exit(1)
|
||||
n = pr.get('number')
|
||||
title = pr.get('title','')
|
||||
state = 'merged' if pr.get('merged') else pr.get('state','?')
|
||||
head = (pr.get('head') or {}).get('ref','?')
|
||||
base = (pr.get('base') or {}).get('ref','?')
|
||||
user = (pr.get('user') or {}).get('login','?')
|
||||
created = (pr.get('created_at') or '')[:19].replace('T',' ')
|
||||
updated = (pr.get('updated_at') or '')[:19].replace('T',' ')
|
||||
merged_at = (pr.get('merged_at') or '')[:19].replace('T',' ')
|
||||
mergeable = pr.get('mergeable')
|
||||
url = pr.get('html_url')
|
||||
|
||||
print(f'#{n} {title}')
|
||||
print(f' state: {state} (mergeable={mergeable})')
|
||||
print(f' branch: {head} → {base}')
|
||||
print(f' author: {user}')
|
||||
print(f' created: {created}')
|
||||
print(f' updated: {updated}')
|
||||
if merged_at: print(f' merged: {merged_at}')
|
||||
print(f' url: {url}')
|
||||
print()
|
||||
body = (pr.get('body') or '').strip()
|
||||
if body:
|
||||
print('--- body ---')
|
||||
# Truncar si es muy largo
|
||||
if len(body) > 4000:
|
||||
body = body[:4000] + '\n[...truncado, total {} chars]'.format(len(pr.get('body','')))
|
||||
print(body)
|
||||
"
|
||||
124
scripts/query.sh
Normal file
124
scripts/query.sh
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — helper REST autenticado contra gitea.nucleoriofrio.com.
|
||||
#
|
||||
# Uso:
|
||||
# query.sh /version # GET /api/v1/version
|
||||
# query.sh /repos/NucleOS/nucleo-infra/pulls # GET
|
||||
# query.sh "/repos/NucleOS/nucleo-infra/pulls?state=open"
|
||||
# query.sh -X POST -H 'Content-Type: application/json' \
|
||||
# --data-binary @body.json /repos/NucleOS/X/pulls
|
||||
#
|
||||
# Auth:
|
||||
# - GITEA_USER_PAT en el entorno → se usa esa (override one-shot para admin).
|
||||
# - Sino GITEA_PAT del .env → el PAT de claudecode0.
|
||||
#
|
||||
# Admin guard:
|
||||
# Bloquea endpoints que requieren admin del org NucleOS (Actions
|
||||
# secrets/variables, /admin/*) salvo que GITEA_USER_PAT esté seteada.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="$SKILL_DIR/.env"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "ERROR: $ENV_FILE no existe. Corré setup.sh primero:" >&2
|
||||
echo " bash $SKILL_DIR/scripts/setup.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${GITEA_BASE_URL:?GITEA_BASE_URL no definido en .env}"
|
||||
|
||||
# ─── Resolver PAT: USER_PAT (override) > GITEA_PAT (default) ────────────
|
||||
if [[ -n "${GITEA_USER_PAT:-}" ]]; then
|
||||
PAT="$GITEA_USER_PAT"
|
||||
USING_USER_PAT=1
|
||||
else
|
||||
: "${GITEA_PAT:?GITEA_PAT vacío en .env. Re-correr setup.sh.}"
|
||||
PAT="$GITEA_PAT"
|
||||
USING_USER_PAT=0
|
||||
fi
|
||||
|
||||
# ─── Parsear args: separar flags de curl del path ───────────────────────
|
||||
args=()
|
||||
path=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-X|--request|-d|--data|--data-raw|--data-binary|--data-urlencode|-H|--header|-o|--output|-T|--upload-file|-F|--form)
|
||||
args+=("$1" "$2"); shift 2
|
||||
;;
|
||||
-X*|--request=*|--data=*|--data-raw=*|--data-binary=*|--header=*)
|
||||
args+=("$1"); shift
|
||||
;;
|
||||
--) shift; break ;;
|
||||
-*) args+=("$1"); shift ;;
|
||||
*) path="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[[ $# -gt 0 && -z "$path" ]] && path="$1"
|
||||
|
||||
if [[ -z "$path" ]]; then
|
||||
cat >&2 <<EOF
|
||||
Uso: query.sh [curl flags] <path>
|
||||
|
||||
Ejemplos:
|
||||
query.sh /version
|
||||
query.sh /repos/NucleOS/nucleo-infra/pulls
|
||||
query.sh "/repos/NucleOS/nucleo-infra/pulls?state=open"
|
||||
query.sh -X POST -H 'Content-Type: application/json' \\
|
||||
--data-binary @body.json /repos/NucleOS/X/pulls
|
||||
|
||||
Ver endpoints.md para la cheat sheet completa.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Asegurar leading /
|
||||
case "$path" in
|
||||
/*) ;;
|
||||
http*) echo "ERROR: pasá solo el path (sin host)." >&2; exit 1 ;;
|
||||
*) path="/$path" ;;
|
||||
esac
|
||||
|
||||
# Prefijar /api/v1 si el path no empieza con /api/
|
||||
if [[ "$path" != /api/* ]]; then
|
||||
full_path="/api/v1${path}"
|
||||
else
|
||||
full_path="$path"
|
||||
fi
|
||||
|
||||
# ─── Admin guard ────────────────────────────────────────────────────────
|
||||
# Bloquear endpoints que necesitan admin del org NucleOS, salvo que
|
||||
# GITEA_USER_PAT esté seteada (override deliberado).
|
||||
if [[ "$USING_USER_PAT" -eq 0 ]]; then
|
||||
guard_path="${full_path%%\?*}"
|
||||
if [[ "$guard_path" =~ ^/api/v1/admin/ ]] \
|
||||
|| [[ "$guard_path" =~ ^/api/v1/orgs/[^/]+/actions/(secrets|variables)(/|$) ]] \
|
||||
|| [[ "$guard_path" =~ ^/api/v1/repos/[^/]+/[^/]+/actions/(secrets|variables)(/|$) ]]; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: este endpoint requiere PAT admin. claudecode0 no es admin del org NucleOS.
|
||||
|
||||
Pedile al usuario un PAT temporal con scope admin, exportalo como:
|
||||
export GITEA_USER_PAT=<el-pat-temporal>
|
||||
|
||||
y re-corré el comando. Apenas termine, **recordale BORRAR el PAT** desde
|
||||
https://gitea.nucleoriofrio.com/user/settings/applications
|
||||
(Gitea no tiene PATs efímeros nativos — el cleanup es manual y obligatorio).
|
||||
|
||||
Path bloqueado: $guard_path
|
||||
EOF
|
||||
exit 3
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Llamar ─────────────────────────────────────────────────────────────
|
||||
exec curl -sS \
|
||||
-H "Authorization: token ${PAT}" \
|
||||
-H "Accept: application/json" \
|
||||
"${args[@]}" \
|
||||
"${GITEA_BASE_URL}${full_path}"
|
||||
219
scripts/repo-create.sh
Normal file
219
scripts/repo-create.sh
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — crear un repo en gitea.nucleoriofrio.com.
|
||||
#
|
||||
# Soporta dos targets:
|
||||
# - usuario autenticado (claudecode0): POST /api/v1/user/repos
|
||||
# - org (NucleOS u otra): POST /api/v1/orgs/{org}/repos
|
||||
#
|
||||
# Regla dura: cualquier repo bajo el usuario `claudecode0` (case-insensitive
|
||||
# match con $GITEA_BOT_USER) DEBE ser público. La justificación es que el
|
||||
# usuario humano necesita poder ver lo que el bot crea — sino la cuenta
|
||||
# claudecode0 sería una caja negra. Si pasás --private a un repo bajo el bot,
|
||||
# el script aborta con exit 5.
|
||||
#
|
||||
# Para repos en orgs, --public o --private es REQUERIDO (forzar decisión
|
||||
# explícita; sin default sorpresa).
|
||||
#
|
||||
# Uso:
|
||||
# repo-create.sh <name> [--owner <user|org>] [--description "..."]
|
||||
# [--public | --private] [--no-init] [--license <tpl>]
|
||||
# [--gitignore <tpl>] [--default-branch <name>] [--template]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUERY="$SKILL_DIR/scripts/query.sh"
|
||||
ENV_FILE="$SKILL_DIR/.env"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "ERROR: $ENV_FILE no existe. Corré setup.sh primero." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${GITEA_BOT_USER:?GITEA_BOT_USER no definido en .env. Re-correr setup.sh.}"
|
||||
|
||||
name=""
|
||||
owner=""
|
||||
description=""
|
||||
visibility="" # "public" | "private" | ""
|
||||
auto_init=1
|
||||
license=""
|
||||
gitignore=""
|
||||
default_branch="main"
|
||||
is_template=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--owner) owner="$2"; shift 2 ;;
|
||||
--description) description="$2"; shift 2 ;;
|
||||
--public) visibility="public"; shift ;;
|
||||
--private) visibility="private"; shift ;;
|
||||
--no-init) auto_init=0; shift ;;
|
||||
--license) license="$2"; shift 2 ;;
|
||||
--gitignore) gitignore="$2"; shift 2 ;;
|
||||
--default-branch) default_branch="$2"; shift 2 ;;
|
||||
--template) is_template=1; shift ;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
Uso: repo-create.sh <name> [opciones]
|
||||
|
||||
Argumentos:
|
||||
<name> Nombre del repo (requerido)
|
||||
|
||||
Owner:
|
||||
--owner <user|org> Default: el usuario del bot (\$GITEA_BOT_USER, "$GITEA_BOT_USER")
|
||||
Pasale "NucleOS" para crear en el org.
|
||||
|
||||
Visibilidad:
|
||||
--public | --private Para orgs, uno es REQUERIDO (sin default).
|
||||
Para el usuario del bot, --private está
|
||||
BLOQUEADO (regla dura: todo lo del bot debe ser
|
||||
público para que el usuario humano pueda verlo).
|
||||
|
||||
Contenido:
|
||||
--description "..." Descripción del repo
|
||||
--no-init No crear README inicial (default: auto-init=true)
|
||||
--license <template> ej. "MIT", "Apache-2.0", "GPL-3.0"
|
||||
--gitignore <template> ej. "Node", "Python", "Go"
|
||||
--default-branch <name> default: main
|
||||
--template Marcar como template repo
|
||||
|
||||
Devuelve URL del repo creado (clone_url + html_url).
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
-*) echo "ERROR: flag desconocida: $1" >&2; exit 2 ;;
|
||||
*)
|
||||
if [[ -z "$name" ]]; then name="$1"
|
||||
else echo "ERROR: argumento extra: $1" >&2; exit 2
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$name" ]]; then
|
||||
echo "ERROR: pasá <name>." >&2
|
||||
echo "Uso: repo-create.sh <name> [opciones] (--help para detalles)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Default owner = bot user
|
||||
if [[ -z "$owner" ]]; then
|
||||
owner="$GITEA_BOT_USER"
|
||||
fi
|
||||
|
||||
# ─── Detectar si owner es el bot (case-insensitive) ─────────────────────
|
||||
shopt -s nocasematch
|
||||
is_bot_owner=0
|
||||
if [[ "$owner" == "$GITEA_BOT_USER" ]]; then
|
||||
is_bot_owner=1
|
||||
fi
|
||||
shopt -u nocasematch
|
||||
|
||||
# ─── Guard: visibilidad ─────────────────────────────────────────────────
|
||||
if [[ "$is_bot_owner" -eq 1 ]]; then
|
||||
# Regla dura: bot user → siempre público
|
||||
if [[ "$visibility" == "private" ]]; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: --private bloqueado para repos bajo el usuario del bot ($GITEA_BOT_USER).
|
||||
|
||||
Regla dura de la skill: todo lo que el bot crea bajo su propio usuario debe
|
||||
ser PÚBLICO para que el usuario humano pueda auditarlo. Sin esa regla, la
|
||||
cuenta del bot sería una caja negra.
|
||||
|
||||
Opciones:
|
||||
- Crearlo público (omitir --private; será público por default)
|
||||
- Crearlo en una org en vez del usuario del bot:
|
||||
repo-create.sh $name --owner NucleOS --private
|
||||
EOF
|
||||
exit 5
|
||||
fi
|
||||
# Default: público
|
||||
visibility="public"
|
||||
else
|
||||
# Org u otro user → visibilidad explícita requerida (sin default sorpresa)
|
||||
if [[ -z "$visibility" ]]; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: para repos en una org (owner=$owner), --public o --private es REQUERIDO.
|
||||
|
||||
Sin default para evitar sorpresas. Pasá una flag explícita:
|
||||
repo-create.sh $name --owner $owner --public
|
||||
repo-create.sh $name --owner $owner --private
|
||||
EOF
|
||||
exit 6
|
||||
fi
|
||||
fi
|
||||
|
||||
# Convertir visibilidad → boolean private
|
||||
case "$visibility" in
|
||||
public) is_private="false" ;;
|
||||
private) is_private="true" ;;
|
||||
*) echo "ERROR interno: visibility=$visibility" >&2; exit 99 ;;
|
||||
esac
|
||||
|
||||
# ─── Construir endpoint ─────────────────────────────────────────────────
|
||||
if [[ "$is_bot_owner" -eq 1 ]]; then
|
||||
endpoint="/user/repos"
|
||||
else
|
||||
endpoint="/orgs/${owner}/repos"
|
||||
fi
|
||||
|
||||
# ─── Build body con Python (UTF-8 safe, mismo patrón que pr-create) ─────
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
BODY_JSON="$TMP_DIR/body.json"
|
||||
|
||||
PY_NAME="$name" PY_DESC="$description" PY_PRIVATE="$is_private" \
|
||||
PY_AUTO_INIT="$auto_init" PY_LICENSE="$license" PY_GITIGNORE="$gitignore" \
|
||||
PY_DEFAULT_BRANCH="$default_branch" PY_TEMPLATE="$is_template" \
|
||||
python -c '
|
||||
import json, os
|
||||
data = {
|
||||
"name": os.environ["PY_NAME"],
|
||||
"description": os.environ.get("PY_DESC", ""),
|
||||
"private": os.environ["PY_PRIVATE"] == "true",
|
||||
"auto_init": os.environ["PY_AUTO_INIT"] == "1",
|
||||
"default_branch": os.environ["PY_DEFAULT_BRANCH"],
|
||||
"template": os.environ["PY_TEMPLATE"] == "1",
|
||||
}
|
||||
if os.environ.get("PY_LICENSE"):
|
||||
data["license"] = os.environ["PY_LICENSE"]
|
||||
if os.environ.get("PY_GITIGNORE"):
|
||||
data["gitignores"] = os.environ["PY_GITIGNORE"]
|
||||
print(json.dumps(data))
|
||||
' > "$BODY_JSON"
|
||||
|
||||
# ─── POST ───────────────────────────────────────────────────────────────
|
||||
echo "→ POST $endpoint" >&2
|
||||
echo " name: $name" >&2
|
||||
echo " owner: $owner $([ "$is_bot_owner" -eq 1 ] && echo "(bot user)" || echo "(org)")" >&2
|
||||
echo " visibility: $visibility" >&2
|
||||
echo " auto_init: $auto_init default_branch: $default_branch" >&2
|
||||
[[ -n "$description" ]] && echo " description: $description" >&2
|
||||
[[ -n "$license" ]] && echo " license: $license" >&2
|
||||
[[ -n "$gitignore" ]] && echo " gitignore: $gitignore" >&2
|
||||
|
||||
resp="$("$QUERY" -X POST \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @"$BODY_JSON" \
|
||||
"$endpoint")"
|
||||
|
||||
echo "$resp" | PYTHONIOENCODING=utf-8 python -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
if d.get('id') and d.get('full_name'):
|
||||
visibility_label = 'private' if d.get('private') else 'public'
|
||||
print(f'OK repo creado: {d[\"full_name\"]} ({visibility_label})')
|
||||
print(f' html_url: {d.get(\"html_url\")}')
|
||||
print(f' clone_url: {d.get(\"clone_url\")}')
|
||||
print(f' ssh_url: {d.get(\"ssh_url\")}')
|
||||
else:
|
||||
print('ERROR:', d.get('message') or d, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
"
|
||||
180
scripts/setup.sh
Normal file
180
scripts/setup.sh
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea skill — setup inicial.
|
||||
#
|
||||
# Idempotente. Cada corrida:
|
||||
# 1. Busca el PAT en la skill bitwarden (item "claudecode0 · Gitea PAT
|
||||
# claude-agent-gitops").
|
||||
# 2. Si hay duplicados, toma el primero y avisa al usuario que limpie.
|
||||
# 3. Escribe ~/.claude/skills/gitea/.env con BASE_URL + PAT + DEFAULT_OWNER.
|
||||
# 4. Valida con GET /api/v1/version → debe responder.
|
||||
#
|
||||
# Re-corré esto cuando rotás el PAT en Gitea y lo guardás de nuevo en bitwarden.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="$SKILL_DIR/.env"
|
||||
ENV_EXAMPLE="$SKILL_DIR/.env.example"
|
||||
|
||||
BW_QUERY="$HOME/.claude/skills/bitwarden/scripts/query.sh"
|
||||
# Matchamos por substring porque los items reales en bitwarden tienen el `·`
|
||||
# doble-encodeado (corrupción histórica del web vault). El substring esquiva
|
||||
# el carácter roto.
|
||||
PAT_NAME_SUBSTR="Gitea PAT claude-agent-gitops"
|
||||
PAT_ITEM_DESCRIPTION="claudecode0 · Gitea PAT claude-agent-gitops"
|
||||
GITEA_BASE_URL="${GITEA_BASE_URL:-https://gitea.nucleoriofrio.com}"
|
||||
DEFAULT_OWNER="${GITEA_DEFAULT_OWNER:-NucleOS}"
|
||||
|
||||
if [[ ! -x "$BW_QUERY" ]]; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: la skill bitwarden no está configurada en $BW_QUERY.
|
||||
|
||||
Setup necesario primero:
|
||||
bash ~/.claude/skills/bitwarden/scripts/setup.sh
|
||||
|
||||
Después re-corré esto.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Buscando PAT en el vault de bitwarden (claudecode0)..."
|
||||
|
||||
# Search por substring corto (el `·` en el nombre URL-encoded confunde a
|
||||
# bw serve). Filtramos por nombre exacto en el python downstream.
|
||||
list_resp="$("$BW_QUERY" "/list/object/items?search=Gitea+PAT+claude-agent-gitops")" || {
|
||||
echo "ERROR: query a bitwarden falló. ¿bw serve arriba? ¿session expirada?" >&2
|
||||
echo " Probá: bash ~/.claude/skills/bitwarden/scripts/setup.sh" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Filtrar matches por substring del nombre. Python lee JSON por stdin y
|
||||
# el substring por env var (export antes del pipe — sino el VAR=val inline
|
||||
# solo aplica al `printf`, no al `python`).
|
||||
export PAT_NAME_SUBSTR
|
||||
mapfile -t pat_ids < <(
|
||||
printf '%s' "$list_resp" | python -c '
|
||||
import json, os, sys
|
||||
target = os.environ["PAT_NAME_SUBSTR"]
|
||||
data = json.load(sys.stdin)
|
||||
items = (data.get("data") or {}).get("data") or data.get("data") or []
|
||||
if isinstance(items, dict):
|
||||
items = items.get("data", [])
|
||||
for it in items:
|
||||
name = it.get("name", "") or ""
|
||||
if target in name:
|
||||
print(it.get("id", ""))
|
||||
' | tr -d '\r'
|
||||
)
|
||||
|
||||
case "${#pat_ids[@]}" in
|
||||
0)
|
||||
cat >&2 <<EOF
|
||||
ERROR: no encontré ningún item con substring "$PAT_NAME_SUBSTR" en el nombre.
|
||||
|
||||
Pasos:
|
||||
1. Generá un PAT en https://gitea.nucleoriofrio.com/user/settings/applications
|
||||
con scopes: read:repo, write:repo, read:issue, write:issue, read:user.
|
||||
2. Guardalo en bitwarden con un nombre que contenga "$PAT_NAME_SUBSTR".
|
||||
(Idealmente: "$PAT_ITEM_DESCRIPTION".)
|
||||
3. Re-corré este setup.
|
||||
|
||||
EOF
|
||||
exit 3
|
||||
;;
|
||||
1)
|
||||
pat_id="${pat_ids[0]}"
|
||||
;;
|
||||
*)
|
||||
pat_id="${pat_ids[0]}"
|
||||
cat >&2 <<EOF
|
||||
⚠️ Hay ${#pat_ids[@]} items en bitwarden con substring "$PAT_NAME_SUBSTR".
|
||||
Tomé el primero (id: $pat_id). Si no es el activo, este setup va a fallar
|
||||
en la validación más abajo.
|
||||
|
||||
Limpiá los duplicados desde la web (https://vault.nucleoriofrio.com) — la
|
||||
skill bitwarden bloquea DELETE así que no podemos borrarlos desde acá.
|
||||
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "→ Extrayendo password del item $pat_id..."
|
||||
pat_value="$("$BW_QUERY" "/object/password/${pat_id}" | python -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
v=d.get('data')
|
||||
if isinstance(v, dict):
|
||||
v=v.get('data')
|
||||
print(v or '')
|
||||
" | tr -d '\r')"
|
||||
|
||||
if [[ -z "$pat_value" ]]; then
|
||||
echo "ERROR: el item existe pero el password vino vacío. Revisá en web." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
echo "→ Escribiendo $ENV_FILE..."
|
||||
{
|
||||
echo "# gitea skill — generado por setup.sh"
|
||||
echo "# NO editar a mano (re-corré setup.sh para refrescar el PAT)."
|
||||
echo ""
|
||||
echo "GITEA_BASE_URL=$GITEA_BASE_URL"
|
||||
echo "GITEA_PAT=$pat_value"
|
||||
echo "GITEA_DEFAULT_OWNER=$DEFAULT_OWNER"
|
||||
} > "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
|
||||
echo "→ Validando con GET /api/v1/version..."
|
||||
version_resp="$(GITEA_PAT="$pat_value" GITEA_BASE_URL="$GITEA_BASE_URL" \
|
||||
curl -sS -m 10 \
|
||||
-H "Authorization: token $pat_value" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_BASE_URL}/api/v1/version")" || {
|
||||
echo "ERROR: GET /version falló. ¿base URL correcta? ¿conexión?" >&2
|
||||
exit 5
|
||||
}
|
||||
|
||||
version="$(echo "$version_resp" | python -c "import json,sys; print(json.load(sys.stdin).get('version','?'))" 2>/dev/null || echo "?")"
|
||||
|
||||
if [[ "$version" == "?" || -z "$version" ]]; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: el server respondió pero no pude parsear la versión.
|
||||
Respuesta cruda:
|
||||
$version_resp
|
||||
|
||||
Posibles causas:
|
||||
- PAT inválido / revocado (limpiá el duplicado en bitwarden)
|
||||
- Base URL mal en .env
|
||||
|
||||
EOF
|
||||
exit 6
|
||||
fi
|
||||
|
||||
# Sanity check del user actual con el PAT — capturamos el login para
|
||||
# escribirlo a .env (lo usa repo-create.sh para detectar user vs. org).
|
||||
user_resp="$(curl -sS -m 10 \
|
||||
-H "Authorization: token $pat_value" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_BASE_URL}/api/v1/user")"
|
||||
username="$(echo "$user_resp" | python -c "import json,sys; print(json.load(sys.stdin).get('login','?'))" 2>/dev/null || echo "?")"
|
||||
|
||||
if [[ "$username" != "?" && -n "$username" ]]; then
|
||||
# Append/replace GITEA_BOT_USER en .env
|
||||
if grep -q '^GITEA_BOT_USER=' "$ENV_FILE" 2>/dev/null; then
|
||||
# Reemplazar línea existente (sed -i no es portable en Git Bash, usar archivo temp)
|
||||
grep -v '^GITEA_BOT_USER=' "$ENV_FILE" > "$ENV_FILE.tmp"
|
||||
echo "GITEA_BOT_USER=$username" >> "$ENV_FILE.tmp"
|
||||
mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
echo "GITEA_BOT_USER=$username" >> "$ENV_FILE"
|
||||
fi
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "→ Listo: Gitea $version | user=$username | $GITEA_BASE_URL"
|
||||
echo ""
|
||||
echo "Próximos pasos:"
|
||||
echo " bash $SKILL_DIR/scripts/query.sh /version"
|
||||
echo " bash $SKILL_DIR/scripts/pr-list.sh NucleOS/nucleo-infra"
|
||||
echo " bash $SKILL_DIR/scripts/actions-list-runs.sh NucleOS/nucleo-infra"
|
||||
Reference in New Issue
Block a user