import: contenido inicial de la skill gitea

This commit is contained in:
2026-04-26 14:24:00 -06:00
parent 28e657a353
commit 768b48003d
15 changed files with 2012 additions and 1 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"