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