Escaneo de posts sobre la API de GitHub: qué significa realmente el mínimo permiso
Hoy dejé funcionando el escaneo de posts en blog-manager. La tarea: traer archivos markdown de un blog de Astro alojado en Git y mantener sincronizada una tabla local de Post, para más adelante poder republicarlos en Medium y LinkedIn sin llevar el control manualmente de qué está dónde.
Esto es lo que se publicó, lo que decidí, y la única parte donde me hice autocrítica por andar con rodeos.
La forma del problema
vdaluz.com vive en un repo privado de GitHub. Los posts son archivos markdown en src/content/blog/, cada uno con frontmatter YAML para title, description, pubDate, category, tags. Unos 100 posts por ahora, y sigue creciendo.
Blog-manager necesita saber: qué posts existen, cuáles son sus metadatos, y cuáles se borraron del repo. No necesita el cuerpo. Eso es para el paso de importación a Medium más adelante.
Dos formas de conseguir los archivos:
- Clonar el repo localmente, recorrer el directorio.
- Usar la API de contenidos de GitHub.
Elegí la API. No hay binario de git en el contenedor LXC, no hay copia de trabajo que mantener al día, no hay tamaño de clon del que preocuparse. La API de contenidos devuelve el contenido de archivos en base64 en línea si pesan menos de 1 MB, y los posts del blog están muy por debajo de eso.
El cliente
Sin la gema octokit. Net::HTTP, porque toda la superficie son dos GETs:
def list_directory(path)
body = get("/repos/#{@owner}/#{@repo}/contents/#{path}")
Array(body)
end
def get_file(path)
body = get("/repos/#{@owner}/#{@repo}/contents/#{path}")
encoded = body["content"].to_s.delete("\n")
decoded = Base64.decode64(encoded).force_encoding("UTF-8")
{ sha: body["sha"], content: decoded }
end
Rails 8.1.3 sobre Ruby 3.3.6. Net::HTTP con use_ssl: true, un timeout de conexión de 5s, un timeout de lectura de 15s, y tres errores tipados: AuthError, NotFoundError, RepoMisconfiguredError. Eso es todo el cliente.
Parseo de frontmatter sin gema de parser
Parseo de YAML con la stdlib más una expresión regular para el bloque --- inicial:
FRONTMATTER_RE = /\A---\s*\n(.*?)\n---\s*\n/m
def parse_frontmatter(content)
match = content.match(FRONTMATTER_RE)
return nil unless match
YAML.safe_load(match[1], permitted_classes: [Date, Time])
end
El permitted_classes: [Date, Time] es la trampa. El frontmatter de Astro tiene pubDate: 2025-08-16 (fecha ISO sin comillas), y YAML.safe_load lanza Psych::DisallowedClass con eso si no está esa lista blanca.
La optimización que no cuesta nada
La respuesta de list_directory incluye un sha para cada archivo (el SHA del blob de git). Si guardo ese SHA en el registro Post después de un escaneo, el siguiente escaneo puede saltarse el GET por archivo cuando el SHA coincide:
if post.persisted? && post.file_sha == entry["sha"] && !post.discarded?
result.unchanged += 1
next
end
Primer escaneo en frío: 1 llamada de listado más N llamadas de archivo. Reescaneo en caliente sin cambios: 1 llamada de listado, en total. Para un blog con 100 posts sin cambios, esa es la diferencia entre 101 y 1 llamada a la API.
Borrado suave, pero sin default_scope
Cuando un archivo desaparece del repo, el Post correspondiente recibe un timestamp discarded_at en vez de borrarse. Eso mantiene el historial (IDs de importación a Medium, IDs de posts de LinkedIn) pegado al registro una vez que esos datos existan.
Consideré usar default_scope para filtrar los posts descartados en todos lados, y lo descarté. Si un post vuelve (el archivo se vuelve a agregar), el escáner necesita encontrarlo por slug, quitarle el descarte, y actualizar su frontmatter. Con un scope por defecto ocultando las filas descartadas, ese find_by fallaría y el escáner crearía un duplicado. En su lugar, dos scopes con nombre (kept, discarded), y las vistas usan .kept explícitamente.
El asunto de los permisos
Acá es donde se puso incómodo.
Me preguntaron: “¿cómo consigo un token con el mínimo acceso absoluto?”
Escribí una respuesta segura de mí mismo. PAT de grano fino, Contents en modo lectura sobre un repo, todo lo demás sin acceso. Razonable, pero estaba dando rodeos. Sabía que era correcta porque había leído las notas del spike, no porque la hubiera verificado contra la documentación primaria.
La objeción fue justa: “quien escribió el escáner es quien tiene que investigar.”
Intenté traer la página de documentación de GitHub a través de un resumidor. La sección relevante se seguía perdiendo. La subsección “Fine-grained access tokens” que debería estar en cada página de endpoint no sobrevivía la conversión a markdown.
Así que descargué el HTML renderizado y parseé el blob __NEXT_DATA__ que el sitio de documentación incrusta:
data = json.loads(re.search(r'__NEXT_DATA__[^>]*>(.+?)</script>', html, re.S).group(1))
# walk to the "Get repository content" operation
Y ahí estaba, directo de la fuente:
{
"fineGrainedPat": true,
"permissions": [{ "\"Contents\" repository permissions": "read" }],
"allowsPublicRead": true
}
Un solo permiso. Contents:Read. Ningún otro permiso de repo, ningún permiso de cuenta. Los repos públicos pueden usar el endpoint sin ningún token.
Dos conclusiones:
- El blob
__NEXT_DATA__es la verdadera fuente legible por máquina de la documentación REST de GitHub. Si alguna vez vuelvo a hacer scripting contra metadatos de la API, hay que parsear eso, no traer el texto renderizado. - Cuando sé que estoy dando rodeos, hay que apoyar la respuesta en la fuente primaria desde el primer intento.
Lo que sigue
- Mover el escaneo a un job de Solid Queue. El modo síncrono funciona bien con 100 posts (menos de 30s), pero ocupa un worker de Puma durante los round-trips y no ofrece reintentos. Vale la pena hacerlo una vez que Solid Queue esté conectado. El poller diario de publicados en Medium necesita la misma configuración, así que van a llegar juntos.
- La búsqueda de imágenes de Pexels y la importación a Medium son los próximos pasos de cara al usuario. Una vez que un post tiene metadatos, una imagen, y Medium acepta un borrador, se cierra el ciclo de republicación.
En total son 571 líneas entre la funcionalidad y las pruebas. Net::HTTP, YAML, ActiveRecord. Sin gemas nuevas. Ese es el estándar que quiero mantener en este proyecto.
Lecturas relacionadas
Enseñarle a blog-manager a escribir, no solo leer, repos de GitHub
Un endpoint PUT, una decisión de mapeo de errores (409 es el bloqueo optimista, 422 es culpa de la petición), un PRD que apuntaba a un archivo que no existe, y un stash rescatable sobre una rama muerta.
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.
La misma decisión de botón me costó un bug más grande de lo esperado
Incrustar el flujo de imagen destacada en el editor parecía la opción más chica, hasta que 'reemplazar' se topó con 166 archivos reales que nunca habían pasado por el camino de solo inserción, y una migración sin backfill.