Files
skill-gitea/scripts/actions-logs.sh
claudecode0 d5e1cf3696 fix: forzar PYTHONIOENCODING=utf-8 en scripts con python -c
En Windows el stdout de Python defaultea a cp1252 y crashea con
UnicodeEncodeError al imprimir literales no-ASCII (e.g. el ✓ del print
de éxito de pr-create.sh). El sintoma era que el PR se creaba OK pero
el script terminaba con exit 1 por el crash del print posterior.

Agrego export PYTHONIOENCODING=utf-8 después de set -euo pipefail en
los 10 scripts que invocan python (todos menos query.sh). Cubre tanto
las invocaciones inline como futuras sin tener que acordarse caso por
caso. Mantiene los literales unicode existentes (✓, →, ─, ⚠️).
2026-04-26 15:00:37 -06:00

292 lines
9.9 KiB
Bash

#!/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
# Forzar UTF-8 en stdout de Python (Windows defaultea a cp1252 y crashea con
# literales no-ASCII). Ver memoria feedback_api_utf8_encoding.md.
export PYTHONIOENCODING=utf-8
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