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

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# gitea skill — config local (NO versionar, chmod 600 best-effort)
#
# El usuario NO completa esto a mano. `setup.sh` lo genera automáticamente:
# - extrae el PAT desde la skill bitwarden (item "claudecode0 · Gitea PAT
# claude-agent-gitops" del vault de claudecode0)
# - escribe los 3 valores acá abajo
# - valida con GET /api/v1/version
#
# Si el PAT rota (revocado y regenerado), basta con re-correr `setup.sh`.
#
# Setup paso a paso en SKILL.md.
# ─── Servidor Gitea ──────────────────────────────────────────────────────
# URL base de la instancia self-hosted. La skill prefija /api/v1 si el path
# del query no empieza con /api/.
GITEA_BASE_URL=https://gitea.nucleoriofrio.com
# ─── PAT del bot claudecode0 ─────────────────────────────────────────────
# Personal Access Token de claudecode0. Scope: push/PR/read-write código y
# issues; NO admin del org NucleOS (no puede tocar Actions secrets/variables).
# Si necesitás operaciones admin, exportá GITEA_USER_PAT=<temporal> en el
# entorno y re-corré el comando — el guard en query.sh lo detecta y bypassea.
GITEA_PAT=
# ─── Owner default ────────────────────────────────────────────────────────
# Cuando un script acepta un repo como `<owner>/<repo>`, este es el owner que
# se usa si pasás solo `<repo>`. El org del beneficio es NucleOS.
GITEA_DEFAULT_OWNER=NucleOS
# ─── Bot user ─────────────────────────────────────────────────────────────
# El username del bot detrás del PAT (típicamente "claudecode0"). repo-create.sh
# lo usa para detectar cuándo el target es el user autenticado vs. una org, y
# para aplicar la regla "claudecode0 → siempre repos públicos".
GITEA_BOT_USER=claudecode0

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Local config con secrets — NUNCA committear
.env
# Cache local (si se llegara a crear)
.cache/
# Temporales
*.tmp
*.bak
*.swp

View File

@@ -1,3 +1,56 @@
# skill-gitea # skill-gitea
Skill local de Claude Code para gitea.nucleoriofrio.com (org NucleOS). PRs + introspeccion de Gitea Actions con filtros precisos para no saturar la ventana de contexto. Skill local de [Claude Code](https://claude.com/claude-code) para
`gitea.nucleoriofrio.com` (Gitea 1.24, org **NucleOS**). Cubre PRs, creación
de repos, y la killer feature: introspección de Gitea Actions runs con
filtros precisos para no saturar la ventana de contexto.
> Mirror público del directorio local `~/.claude/skills/gitea/` en la PC del
> usuario humano. Existe para que el usuario pueda **auditar** lo que el bot
> hace contra su instancia de Gitea (creación de repos, PRs, lectura de
> logs). Clonar y correr en otra máquina **no funciona out-of-the-box** —
> requiere PAT del bot en `.env` (no incluido) y la skill `bitwarden`
> configurada para extraerlo.
## Doc principal
Ver **[SKILL.md](SKILL.md)** — diseño, modelo de seguridad (admin guard +
anti-AI guard + visibilidad pública del bot), lifecycle, ejemplos.
Cheat sheet de endpoints en **[endpoints.md](endpoints.md)**.
## Estructura
```
.
├── SKILL.md ← docs canónicas
├── endpoints.md ← cheat sheet API Gitea 1.24
├── .env.example ← plantilla de config (PAT, bot user, etc.)
├── .gitignore
├── README.md ← este archivo
└── scripts/
├── setup.sh ← extrae PAT desde skill bitwarden + valida
├── query.sh ← helper REST con admin guard
├── pr-list.sh ← lista PRs (state filter)
├── pr-view.sh ← detalle de un PR
├── pr-comments.sh ← lista comments (requiere PAT con read:issue)
├── pr-create.sh ← crear PR (anti-AI guard + UTF-8 safe)
├── repo-create.sh ← crear repo (regla dura: bot user → siempre público)
├── actions-list-runs.sh ← lista runs (filtros client-side)
├── actions-view.sh ← detalle de un run + probe de job_id
└── actions-logs.sh ← logs con --tail/--head/--lines/--grep/--errors
```
## Lo que NO está en este repo
- `.env` (PAT del bot, base URL si fuera distinta a producción)
Vive solo en la PC del usuario humano. Se regenera con `bash scripts/setup.sh`
si el bot tiene la skill `bitwarden` con un item llamado "Gitea PAT
claude-agent-gitops".
## Dependencias
- Python 3 (parsear JSON, escapar bodies UTF-8)
- `curl` (Git Bash en Windows lo trae)
- skill **bitwarden** sibling (para extraer el PAT en `setup.sh`)

347
SKILL.md Normal file
View File

@@ -0,0 +1,347 @@
---
name: gitea
description: Da acceso a la instancia self-hosted de Gitea del beneficio Rio Frio (gitea.nucleoriofrio.com, org NucleOS). Úsala para crear/listar/ver PRs, leer comentarios, crear repos (en NucleOS o en tu propio usuario claudeCode0 — los del usuario del bot quedan SIEMPRE públicos por regla dura), y la killer feature — verificar runs de Gitea Actions post-merge (status, logs filtrados con --tail/--head/--errors/--grep, sin volcar el log entero a la ventana). Cubre los casos "mergeé el PR, ¿deployó OK?", "creame un repo nuevo para la skill X", reemplaza los curl ad-hoc anteriores. Ejemplos: "listame los PRs abiertos en nucleo-infra", "creá un PR desde la branch X a main", "qué pasó con el último deploy", "buscame los errores en el run 11 del workflow deploy-infra", "mostrame las últimas 30 líneas de logs del run que falló", "creá un repo skill-foo en mi usuario", "crea repo en NucleOS llamado servicio-X privado".
allowed-tools: Bash, Read, Grep
---
# Gitea — skill de PRs + Actions de gitea.nucleoriofrio.com
## Qué es esto
Skill para interactuar con la instancia **self-hosted de Gitea** del beneficio
Rio Frio (`gitea.nucleoriofrio.com`, Gitea 1.24.7) hostea el org **`NucleOS`**.
Resuelve dos casos de uso principales:
1. **Crear/leer PRs** sin escribir curl ad-hoc cada vez (con guards anti-AI
attribution y body UTF-8 safe).
2. **Killer feature**: introspección de runs de Gitea Actions post-merge —
`actions-list-runs`, `actions-view`, `actions-logs`. Cierra el loop GitOps:
yo mergeo un PR, el workflow `deploy-infra.yml` dispara, y con la skill
verifico que deployó OK sin necesitar SSH a nucleo001.
Las operaciones admin (Actions secrets/variables) están **bloqueadas** porque
el PAT de `claudecode0` no es admin del org. Hay un override `GITEA_USER_PAT`
para esos casos one-shot.
- **Server**: `https://gitea.nucleoriofrio.com`
- **Auth**: `Authorization: token <PAT>` (PAT de `claudecode0` en `.env`,
extraído de la skill `bitwarden` por `setup.sh`)
- **Override admin**: `GITEA_USER_PAT=...` en el entorno (one-shot temporal)
## Cómo invocarla correctamente
Antes de cualquier query:
1. **Verificá `~/.claude/skills/gitea/.env`**. Si no existe o está vacío:
```bash
bash ~/.claude/skills/gitea/scripts/setup.sh
```
Eso extrae el PAT desde la skill `bitwarden` (item con substring "Gitea PAT
claude-agent-gitops") y lo escribe al `.env`. Re-corré `setup.sh` cada vez
que rotás el PAT.
2. **Si una query devuelve 401**: el PAT está revocado o el item correcto
en bitwarden cambió. Re-corré `setup.sh`. Si persiste, podría ser que
`setup.sh` está agarrando el PAT duplicado equivocado (hay dos items con
el mismo nombre en bitwarden, ver "Errores típicos").
## Scripts disponibles
| Script | Propósito |
|---|---|
| `setup.sh` | Extrae PAT de bitwarden, escribe `.env`, valida con `/version` |
| `query.sh` | Helper REST genérico con admin-guard (foundation de todos los demás) |
| `pr-list.sh` | Lista PRs (con `--state open\|closed\|all`, `--limit N`) |
| `pr-view.sh` | Ver detalle de un PR (incluye body completo) |
| `pr-comments.sh` | Lista comentarios de un PR (requiere PAT con `read:issue`) |
| `pr-create.sh` | Crear PR con guards anti-AI-attribution + body UTF-8 safe |
| `repo-create.sh` | Crear repo (default owner = bot user; **regla dura**: bot user → siempre público) |
| `actions-list-runs.sh` | Lista runs (filtros client-side: workflow, branch, status, event) |
| `actions-view.sh` | Detalle de un run + probe del job_id |
| `actions-logs.sh` | Lee logs con filtros precisos (--tail/--head/--lines/--grep/--errors) |
Ver `endpoints.md` para los endpoints crudos de la API.
## Modelo de seguridad
### Admin guard en `query.sh`
`query.sh` bloquea cualquier path que matchee:
- `/admin/*` (operaciones admin del server)
- `/orgs/{org}/actions/(secrets|variables)/*`
- `/repos/{o}/{r}/actions/(secrets|variables)/*`
Mensaje del guard: pide al usuario un PAT temporal con scope admin, lo exporta
como `GITEA_USER_PAT=...`, y le **recuerda BORRAR el PAT** desde
`https://gitea.nucleoriofrio.com/user/settings/applications` apenas termine
(Gitea no tiene PATs efímeros nativos).
```bash
# Bypass legítimo del guard:
export GITEA_USER_PAT=<el-pat-temporal-admin>
bash ~/.claude/skills/gitea/scripts/query.sh /repos/NucleOS/X/actions/secrets
unset GITEA_USER_PAT # limpiar al terminar
```
### Anti-AI-attribution guard en `pr-create.sh`
**Regla dura** (ver memoria `feedback_no_ai_attribution.md`). Antes de POST,
escanea title + body por (case-insensitive):
- `Co-Authored-By: Claude` (o `Anthropic`)
- `🤖`
- `Generated with [Claude Code]` / `Generated with Claude` / `Generated with Anthropic`
- `Created with Claude` / `Powered by Claude` / `Made with Claude`
Si match → exit 4 con mensaje + las líneas ofensivas. Esto es **imposibilitar**,
no advertir — el usuario considera deshonesto darle crédito a Claude/Anthropic
por trabajo del usuario.
### Visibilidad de repos del bot (regla dura)
`repo-create.sh` rechaza `--private` cuando el owner es el bot user
(case-insensitive match contra `$GITEA_BOT_USER`). Justificación: la cuenta
`claudeCode0` es del bot, pero el usuario humano tiene que poder auditarla
sin login. Si los repos del bot fueran privados, sería una caja negra. Para
algo realmente privado, crearlo bajo la org NucleOS.
Para repos en orgs (NucleOS u otra), el flag `--public` o `--private` es
**requerido** — sin default sorpresa. Forzá la decisión consciente.
### Scope del PAT de claudecode0
El PAT cacheado tiene scope: `write:organization`, `write:package`,
`write:repository`, `write:user`. **NO incluye `read:issue` / `write:issue`**,
así que `pr-comments.sh` (que lee issue comments) puede devolver 403. Si el
usuario quiere usar `pr-comments` o crear PRs con descripciones largas que
generen comentarios, debe regenerar el PAT con esos scopes adicionales y
re-correr `setup.sh`.
## Cuándo usar qué endpoint
### Caso típico: "mergeé el PR, ¿deployó OK?"
Después de mergear un PR a main en `nucleo-infra`, el workflow corre. Para
verificar:
```bash
# 1. Ver el último run del workflow:
bash ~/.claude/skills/gitea/scripts/actions-list-runs.sh NucleOS/nucleo-infra --limit 3
# 2. Si está in_progress, esperar 30-60s y reintentar.
# Si terminó, ver los logs:
bash ~/.claude/skills/gitea/scripts/actions-logs.sh NucleOS/nucleo-infra <run_number>
# 3. Si falló, buscar errores específicos:
bash ~/.claude/skills/gitea/scripts/actions-logs.sh NucleOS/nucleo-infra <run> --errors -C 3
```
### "Quiero crear un PR"
Para body con tildes/ñ, **siempre** pasarlo via `--body-file`. NO usar heredoc
inline (memoria `feedback_api_utf8_encoding.md`):
```bash
# Crear el body en un archivo (con Write tool, UTF-8 nativo):
cat > /c/Users/jodar/AppData/Local/Temp/pr-body.md <<'EOF'
## Qué cambia
Esto arregla el bug X — resumen de cambios.
## Por qué
[...]
EOF
bash ~/.claude/skills/gitea/scripts/pr-create.sh NucleOS/nucleo-infra \
--head fix/foo \
--title "fix(x): short imperative" \
--body-file /c/Users/jodar/AppData/Local/Temp/pr-body.md
```
Para body simple sin caracteres especiales, `--body "..."` inline también va.
### "Listame PRs abiertos"
```bash
bash ~/.claude/skills/gitea/scripts/pr-list.sh NucleOS/nucleo-infra
# Default state=open, limit=20.
```
### "Ver detalle del PR #14"
```bash
bash ~/.claude/skills/gitea/scripts/pr-view.sh NucleOS/nucleo-infra 14
```
### "Mostrame solo los runs que fallaron"
```bash
bash ~/.claude/skills/gitea/scripts/actions-list-runs.sh NucleOS/nucleo-infra \
--status failure --limit 20
```
### "Buscá errores en el run 7"
```bash
bash ~/.claude/skills/gitea/scripts/actions-logs.sh NucleOS/nucleo-infra 7 --errors -C 3
# --errors es shortcut para --grep '(error|fail|fatal|exception|panic|...)'
```
### "Quiero el log entero del run X para grep-ear con mis tools"
```bash
bash ~/.claude/skills/gitea/scripts/actions-logs.sh NucleOS/nucleo-infra X \
--save /c/Users/jodar/AppData/Local/Temp/run-X.log
# Guarda raw (sin sanitizar) para preservar fidelidad.
```
### "Creá un repo nuevo en mi (bot) usuario"
Default owner = `$GITEA_BOT_USER` (claudeCode0). **Siempre público** por
regla dura — `--private` está bloqueado bajo el bot user (exit 5):
```bash
bash ~/.claude/skills/gitea/scripts/repo-create.sh skill-foo \
--description "Skill X — qué hace" --license "MIT" --gitignore "Node"
# → claudeCode0/skill-foo (public), html_url + clone_url + ssh_url
```
### "Creá un repo en NucleOS"
Para org, `--public` o `--private` es **requerido** (exit 6 si no pasás
ninguno — sin default sorpresa):
```bash
bash ~/.claude/skills/gitea/scripts/repo-create.sh nuevo-servicio \
--owner NucleOS --private --description "..." --gitignore "Go"
```
## Reglas de comportamiento
### Filtros sobre logs son obligatorios
Los runs de `deploy-infra` tienen ~1700 líneas (post-sanitización). Volcar
entero satura mi ventana. **Default es siempre summary** (header + tail 5/40
según status). Solo usar `--full` cuando es chico, y nunca sin `--i-mean-it`
si supera 1000 líneas.
### Workflow GitOps (memoria `feedback_swarm_changes_via_gitea.md`)
Toda modificación al Swarm va por PR + workflow. Después de mergear, **antes
de declarar el cambio listo**, verificar con `actions-logs` que el deploy
terminó OK. Sino, esa "termina" es prematura.
### Operaciones disruptivas requieren OK explícito
`pr-merge` no está en la skill (out of scope). Mergear con la API requiere
construir el endpoint manualmente con `query.sh` y antes pedir confirmación
explícita al usuario — un merge dispara deploy a producción. Memoria
`feedback_disruptive_actions.md` aplica.
### PAT duplicado en bitwarden
Hay 2 items con substring `Gitea PAT claude-agent-gitops` en el vault de
`claudecode0`. `setup.sh` toma el primero. Si la validación falla:
- Listar los items: `bash ~/.claude/skills/bitwarden/scripts/query.sh "/list/object/items?search=Gitea"`
- Pedirle al usuario que limpie el duplicado desde
`https://vault.nucleoriofrio.com` (la skill `bitwarden` tiene DELETE bloqueado).
## Errores típicos
| Error | Causa | Qué hacer |
|---|---|---|
| `setup.sh` "no encontré ningún item..." | El PAT no está guardado en bitwarden | Generar PAT en Gitea + guardarlo en bitwarden con substring "Gitea PAT claude-agent-gitops" |
| Validación 401 | PAT revocado o duplicado equivocado | Limpiar duplicados desde web vault, regenerar si es necesario, re-correr `setup.sh` |
| `query.sh` admin guard exit 3 | Endpoint requiere admin | Pedir PAT temporal admin al usuario, exportar `GITEA_USER_PAT`, recordar borrarlo apenas termine |
| `pr-create.sh` exit 4 | Body con AI attribution | Remover los markers (regla dura) |
| `pr-comments.sh` 403 | PAT sin scope `read:issue` | Regenerar PAT con `read:issue`+`write:issue`, re-correr `setup.sh` |
| `actions-logs.sh` "no pude resolver job_id" | Probe de ±10 falló (Gitea no expone listado de jobs) | Probar con `--job <jid>` directo; rangear con `query.sh /repos/.../actions/jobs/<jid>/logs` manualmente |
| `actions-list-runs` 0 results con filtro | Filtros son client-side; el server solo respeta `limit` | Subir `--limit` (default 10) para incluir runs más viejos |
## Lifecycle: cuándo correr cada script
| Script | Cuándo |
|---|---|
| `setup.sh` | (a) Primera vez. (b) Cuando el PAT rota. (c) Cuando agregás scopes nuevos al PAT. (d) Cuando 401 inesperado |
| `query.sh` | Endpoint nuevo o ad-hoc no cubierto por otros scripts |
| `pr-*.sh` | Operaciones de PRs |
| `actions-*.sh` | Verificar deploys, debug runs, etc. |
## Setup inicial (lo hace el usuario UNA vez)
### 1. Generar PAT en Gitea
- `https://gitea.nucleoriofrio.com/user/settings/applications` (logueado como `claudecode0`)
- "Generate New Token" con scopes:
- `read:repo`, `write:repo` — push, leer/crear PRs, branches
- `read:issue`, `write:issue` — comentarios y descripciones de PRs
- `read:user` — sanity check de auth
- (NO usar admin scopes acá; eso queda para PATs temporales)
- Copiar el token (sólo se ve una vez).
### 2. Guardar en bitwarden (vault de claudecode0)
- Web: `https://vault.nucleoriofrio.com` → login como `claudecode0`
- New item: name = `claudecode0 · Gitea PAT claude-agent-gitops`
(cualquier nombre con substring `Gitea PAT claude-agent-gitops` sirve)
- Username: `claudecode0`. Password: el PAT.
- Save.
### 3. Correr setup
```bash
bash ~/.claude/skills/gitea/scripts/setup.sh
```
Esperado:
```
→ Listo: Gitea 1.24.7 | user=claudeCode0 | https://gitea.nucleoriofrio.com
```
### 4. Probar
```bash
bash ~/.claude/skills/gitea/scripts/pr-list.sh NucleOS/nucleo-infra
bash ~/.claude/skills/gitea/scripts/actions-list-runs.sh NucleOS/nucleo-infra --limit 3
```
## Limitaciones conocidas de la API de Gitea 1.24
- **No hay endpoint global de runs**: `/actions/runs` 404. Usamos
`/actions/tasks` que sí existe pero solo acepta `page`+`limit` — los demás
filtros (workflow, branch, status, event) son client-side.
- **No hay endpoint para listar jobs de un run**: `/actions/runs/{id}/jobs`
404. La skill hace **probe** de job_ids en `task_id ± 10` matcheando la
primera línea del log con `received task <task_id>`. Si tu run tiene
múltiples jobs o el offset es mayor, falla — pasale `--job <jid>` directo.
- **Logs por job son texto plano** (`/actions/jobs/{job_id}/logs`). Logs por
run completo (`/actions/runs/{id}/logs`) devuelven zip — la skill **nunca
los toca** porque no son procesables desde Bash sin extraer.
- **Actor del run no expuesto**: `/actions/tasks` no devuelve quién disparó
el run. Si querés saber quién mergeó, mirá el merge commit con `git log`.
## Qué NO hace esta skill
- **No mergea PRs** (out of scope; disruptive — dispara deploy a prod, requiere
confirmación explícita del usuario y prefijar `query.sh` manualmente).
- **No comenta PRs** (POST a issues/comments — out of scope; el PAT
tampoco tiene scope para issues por default).
- **No gestiona issues, releases, webhooks ni runners**. Out of scope.
- **No lee zip de logs run-level**. Sólo job-level texto plano.
- **No hace AI attribution** en commits ni PRs. Imposible de bypassear desde
esta skill (regla dura).
## Archivos de la skill
| Archivo | Qué tiene |
|---|---|
| `SKILL.md` | Este archivo |
| `endpoints.md` | Cheat sheet de la API de Gitea 1.24 (lo relevante) |
| `.env.example` | Plantilla de config |
| `.env` | Generado por `setup.sh` (NO versionado, contiene el PAT) |
| `scripts/setup.sh` | Extrae PAT de bitwarden + valida |
| `scripts/query.sh` | Helper REST con admin guard |
| `scripts/pr-*.sh` | Wrappers de PRs |
| `scripts/actions-*.sh` | Wrappers de Gitea Actions |
## Referencias
- API spec oficial Gitea: https://docs.gitea.com/api/1.24/
- Swagger JSON de la instancia: `https://gitea.nucleoriofrio.com/swagger.v1.json`
- `endpoints.md` — cheat sheet de los endpoints relevantes

146
endpoints.md Normal file
View File

@@ -0,0 +1,146 @@
# Gitea 1.24 — endpoint cheat sheet
Base URL: `https://gitea.nucleoriofrio.com/api/v1` (la skill prefija `/api/v1`
si el path no empieza con `/api/`).
Auth: `Authorization: token <PAT>`. Todas las queries pasan por
`scripts/query.sh` salvo que digamos lo contrario.
## Health
| Endpoint | Notas |
|---|---|
| `GET /version` | Health check. Devuelve `{"version":"1.24.7"}` |
| `GET /user` | El usuario detrás del PAT actual |
## Repos
| Endpoint | Notas |
|---|---|
| `POST /user/repos` | Crear repo bajo el user autenticado (claudeCode0). Body: ver schema abajo. La skill bloquea `private:true` acá (regla dura) |
| `POST /orgs/{org}/repos` | Crear repo bajo una org (NucleOS). Mismo body |
| `GET /users/{user}/repos` | Listar repos públicos de un user |
| `GET /orgs/{org}/repos` | Listar repos de una org |
| `GET /repos/{o}/{r}` | Detalle de un repo |
| `DELETE /repos/{o}/{r}` | Borrar repo (requiere PAT con `delete_repo` o admin) |
### Schema de creación (POST body)
```json
{
"name": "skill-foo", // required
"description": "...", // opcional
"private": false, // default false
"auto_init": true, // crear con README inicial
"default_branch": "main",
"license": "MIT", // opcional, template name
"gitignores": "Node", // opcional, template name (case-sensitive)
"readme": "Default", // opcional, README template
"template": false // marcar como template repo
}
```
Listas de templates válidos: ver
`https://gitea.nucleoriofrio.com/api/v1/repos/issue/templates` no aplica;
los template names son los mismos que GitHub usa para
gitignore (Node, Python, Go, etc.) y para licenses (MIT, Apache-2.0, GPL-3.0).
Para gitignores no comunes (ej. "Bash"), Gitea responde 400 silencioso —
omitir el campo y crear un .gitignore manualmente después.
## Pull Requests
| Endpoint | Filtros / body |
|---|---|
| `GET /repos/{o}/{r}/pulls` | `?state=open\|closed\|all`, `?limit=N`, `?page=N`, `?sort=...` |
| `GET /repos/{o}/{r}/pulls/{number}` | Detalle completo |
| `POST /repos/{o}/{r}/pulls` | Body: `{title, body, head, base}`. `head` = `branch` (mismo repo) o `user:branch` (fork) |
| `GET /repos/{o}/{r}/issues/{n}/comments` | PRs son issues; este es el endpoint de comments. Requiere PAT con `read:issue` |
| `POST /repos/{o}/{r}/issues/{n}/comments` | Crear comment (out of scope en MVP) |
| `POST /repos/{o}/{r}/pulls/{n}/merge` | Mergear (out of scope — disruptive) |
### Schema mínimo PR create (POST body)
```json
{
"title": "fix(scope): short imperative",
"body": "## Context\n...",
"head": "feature-branch",
"base": "main"
}
```
## Gitea Actions
### Discovery / metadata
| Endpoint | Notas |
|---|---|
| `GET /repos/{o}/{r}/actions/workflows` | Lista workflows. `id` = filename (ej. `deploy-infra.yml`) |
| `GET /repos/{o}/{r}/actions/workflows/{wf_id}` | Single workflow |
| `GET /repos/{o}/{r}/actions/workflows/{wf_id}/timing` | Tiempos del workflow |
### Runs (Gitea los llama "tasks")
| Endpoint | Notas |
|---|---|
| `GET /repos/{o}/{r}/actions/tasks` | **Lista todos los runs** (filtros server-side: solo `page`+`limit`). Devuelve `{workflow_runs: [{id, run_number, status, event, head_branch, head_sha, workflow_id, display_title, created_at, updated_at, run_started_at, url, name}], total_count}` |
| `GET /repos/{o}/{r}/actions/runs/{run}/artifacts` | Artifacts de un run |
**No existen** estos endpoints en Gitea 1.24 (sí en GitHub):
- `GET /actions/runs` (sin scope a workflow)
- `GET /actions/runs/{id}` (single run plano)
- `GET /actions/runs/{id}/jobs`
- `GET /actions/runs/{id}/logs` plano (existe pero devuelve zip — no usable)
### Jobs
| Endpoint | Notas |
|---|---|
| `GET /repos/{o}/{r}/actions/jobs/{job_id}/logs` | **Texto plano** del log de un job. Único path para leer logs desde la skill |
**No existe** `GET /actions/jobs/{job_id}` (single job detail). Para
saber qué `job_id` corresponde a un `task_id`, la skill hace probe (rango
`task_id ± 10`, match por primera línea `received task <task_id> `).
### Artifacts
| Endpoint | Notas |
|---|---|
| `GET /repos/{o}/{r}/actions/artifacts` | Lista artifacts del repo |
| `GET /repos/{o}/{r}/actions/artifacts/{id}` | Single artifact metadata |
| `GET /repos/{o}/{r}/actions/artifacts/{id}/zip` | Download artifact |
### Bloqueado por la skill (admin guard)
Estos endpoints requieren PAT admin del org. La skill aborta con exit 3 si los
llamás (override: `export GITEA_USER_PAT=<temporal>`):
| Endpoint | Verbo |
|---|---|
| `/admin/*` | cualquiera |
| `/orgs/{org}/actions/secrets/*` | GET/PUT/DELETE |
| `/orgs/{org}/actions/variables/*` | GET/POST/PUT/DELETE |
| `/repos/{o}/{r}/actions/secrets/*` | GET/PUT/DELETE |
| `/repos/{o}/{r}/actions/variables/*` | GET/POST/PUT/DELETE |
## Códigos de error comunes
| HTTP | Causa | Qué hacer |
|---|---|---|
| 401 | PAT inválido / revocado | Re-correr `setup.sh` (puede haber agarrado el duplicado equivocado en bitwarden) |
| 403 | Scope insuficiente del PAT (típico: `read:issue` faltante) | Regenerar PAT con scopes correctos, re-correr `setup.sh`, o pedir PAT admin temporal |
| 404 | Repo/PR/run no existe (o no tenés acceso) | Verificar el path y los args |
| 422 | Body POST inválido (ej. PR sin head/base) | Revisar schema |
| `404 page not found` (texto crudo) | Endpoint no existe en Gitea 1.24 (ej. `/actions/runs/{id}/jobs`) | Usar el endpoint alternativo (`/actions/tasks`, probe de job_id) |
## Querystring params típicos
- **Paginación**: `?page=N&limit=N`. Default `page=1, limit=10` (varía por endpoint).
- **Sort** (en algunos endpoints): `?sort=newest|oldest|...`
- **State**: `?state=open|closed|all` (PRs, issues)
## Referencias
- Spec oficial: https://docs.gitea.com/api/1.24/
- Swagger JSON de la instancia: `https://gitea.nucleoriofrio.com/swagger.v1.json`
(descargá con curl + python para inspeccionar paths nuevos)
- Cuenta del bot: https://gitea.nucleoriofrio.com/claudecode0
- Org NucleOS: https://gitea.nucleoriofrio.com/NucleOS

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"