Saltar al contenido
Development

Escaneo de posts sobre la API de GitHub: qué significa realmente el mínimo permiso

Por Victor Da Luz
railsrubygithub-apidev-logblog-manager

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:

  1. Clonar el repo localmente, recorrer el directorio.
  2. 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:

  1. 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.
  2. 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