From 768b48003d1d0968e23d5732151e05727c6eb04b Mon Sep 17 00:00:00 2001 From: claudecode0 Date: Sun, 26 Apr 2026 14:24:00 -0600 Subject: [PATCH] import: contenido inicial de la skill gitea --- .env.example | 34 ++++ .gitignore | 10 + README.md | 55 +++++- SKILL.md | 347 +++++++++++++++++++++++++++++++++++ endpoints.md | 146 +++++++++++++++ scripts/actions-list-runs.sh | 120 ++++++++++++ scripts/actions-logs.sh | 287 +++++++++++++++++++++++++++++ scripts/actions-view.sh | 108 +++++++++++ scripts/pr-comments.sh | 57 ++++++ scripts/pr-create.sh | 163 ++++++++++++++++ scripts/pr-list.sh | 84 +++++++++ scripts/pr-view.sh | 79 ++++++++ scripts/query.sh | 124 +++++++++++++ scripts/repo-create.sh | 219 ++++++++++++++++++++++ scripts/setup.sh | 180 ++++++++++++++++++ 15 files changed, 2012 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 SKILL.md create mode 100644 endpoints.md create mode 100644 scripts/actions-list-runs.sh create mode 100644 scripts/actions-logs.sh create mode 100644 scripts/actions-view.sh create mode 100644 scripts/pr-comments.sh create mode 100644 scripts/pr-create.sh create mode 100644 scripts/pr-list.sh create mode 100644 scripts/pr-view.sh create mode 100644 scripts/query.sh create mode 100644 scripts/repo-create.sh create mode 100644 scripts/setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fcaed3 --- /dev/null +++ b/.env.example @@ -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= 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 `/`, este es el owner que +# se usa si pasás solo ``. 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32de886 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Local config con secrets — NUNCA committear +.env + +# Cache local (si se llegara a crear) +.cache/ + +# Temporales +*.tmp +*.bak +*.swp diff --git a/README.md b/README.md index 34bbe86..0f37b59 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,56 @@ # 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. \ No newline at end of file +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`) diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..ebfeca6 --- /dev/null +++ b/SKILL.md @@ -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 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= +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 + +# 3. Si falló, buscar errores específicos: +bash ~/.claude/skills/gitea/scripts/actions-logs.sh NucleOS/nucleo-infra --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 ` directo; rangear con `query.sh /repos/.../actions/jobs//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 `. Si tu run tiene + múltiples jobs o el offset es mayor, falla — pasale `--job ` 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 diff --git a/endpoints.md b/endpoints.md new file mode 100644 index 0000000..5b5d6e9 --- /dev/null +++ b/endpoints.md @@ -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 `. 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 `). + +### 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=`): + +| 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 diff --git a/scripts/actions-list-runs.sh b/scripts/actions-list-runs.sh new file mode 100644 index 0000000..a09267c --- /dev/null +++ b/scripts/actions-list-runs.sh @@ -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 / [--workflow ] +# [--branch ] [--status ] +# [--event ] [--actor ] +# [--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 </ [opciones] + +Opciones (todas client-side, el server solo respeta page+limit): + --workflow ej. deploy-infra.yml (matchea workflow_id) + --branch ej. main (matchea head_branch) + --status success | failure | running | waiting | cancelled + --event 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á /." >&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}") +' diff --git a/scripts/actions-logs.sh b/scripts/actions-logs.sh new file mode 100644 index 0000000..c423868 --- /dev/null +++ b/scripts/actions-logs.sh @@ -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 # resumen (auto-tail si falló) +# actions-logs.sh --tail N +# actions-logs.sh --head N +# actions-logs.sh --lines X-Y +# actions-logs.sh --grep PAT [-C N] +# actions-logs.sh --errors [-C N] +# actions-logs.sh --count +# actions-logs.sh --save /tmp/log.txt +# actions-logs.sh --full [--i-mean-it] +# actions-logs.sh --job ... # skip probe +# +# 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 </ [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á / " >&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 directo. Listá jobs candidatos con:" >&2 + echo " for j in \$(seq ); 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 diff --git a/scripts/actions-view.sh b/scripts/actions-view.sh new file mode 100644 index 0000000..a596304 --- /dev/null +++ b/scripts/actions-view.sh @@ -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 " +# +# Uso: +# actions-view.sh / + +set -euo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +QUERY="$SKILL_DIR/scripts/query.sh" + +if [[ $# -lt 2 ]]; then + cat >&2 </ +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 ". 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//logs" >&2 +fi diff --git a/scripts/pr-comments.sh b/scripts/pr-comments.sh new file mode 100644 index 0000000..30d7ee9 --- /dev/null +++ b/scripts/pr-comments.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# gitea skill — leer comentarios de un PR. +# +# Uso: +# pr-comments.sh / +# pr-comments.sh +# +# 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 </ + pr-comments.sh +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() +" diff --git a/scripts/pr-create.sh b/scripts/pr-create.sh new file mode 100644 index 0000000..a085503 --- /dev/null +++ b/scripts/pr-create.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# gitea skill — crear un PR. +# +# Uso: +# pr-create.sh / --head [--base main] --title "..." \ +# (--body "..." | --body-file ) [--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 </ --head [--base main] \\ + --title "..." (--body "..." | --body-file ) [--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: / --head --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 <&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) +" diff --git a/scripts/pr-list.sh b/scripts/pr-list.sh new file mode 100644 index 0000000..2d80d1f --- /dev/null +++ b/scripts/pr-list.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# gitea skill — listar PRs de un repo. +# +# Uso: +# pr-list.sh / [--state open|closed|all] [--limit N] +# +# Si pasás solo , 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 </ [--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á / o solo ." >&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}') +" diff --git a/scripts/pr-view.sh b/scripts/pr-view.sh new file mode 100644 index 0000000..d1324f4 --- /dev/null +++ b/scripts/pr-view.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# gitea skill — ver detalle de un PR. +# +# Uso: +# pr-view.sh / +# pr-view.sh # 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 </ + pr-view.sh + +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) +" diff --git a/scripts/query.sh b/scripts/query.sh new file mode 100644 index 0000000..dd2391d --- /dev/null +++ b/scripts/query.sh @@ -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 < + +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 < + +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}" diff --git a/scripts/repo-create.sh b/scripts/repo-create.sh new file mode 100644 index 0000000..66013cc --- /dev/null +++ b/scripts/repo-create.sh @@ -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 [--owner ] [--description "..."] +# [--public | --private] [--no-init] [--license ] +# [--gitignore ] [--default-branch ] [--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 < [opciones] + +Argumentos: + Nombre del repo (requerido) + +Owner: + --owner 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