Enseñarle a blog-manager a escribir, no solo leer, repos de GitHub
blog-manager ha sido de solo lectura contra los repos de blogs desde la primera versión: escanear un repo, parsear el frontmatter, sindicar a Medium y Dev.to. Cada escritura pasaba por un navegador, a mano, en el repo mismo. Este fue el primer issue que le pidió a la app hacer un commit de vuelta.
Lo que estaba tratando de hacer
El alcance era angosto a propósito: no el editor, solo la primitiva debajo de él. Un método que toma una ruta, contenido, y un mensaje de commit, y lo convierte en una llamada PUT /repos/{owner}/{repo}/contents/{path}, creando un archivo si es nuevo, actualizándolo si se pasa el sha actual del blob. El editor que en realidad llama a esto es un trabajo aparte, para más adelante.
Lo que construí
Ya existía un Github::ContentClient, un wrapper de Net::HTTP hecho a mano, sin Octokit, que el escáner de posts y el código de sindicación usan para GET. Lo extendí en vez de escribir una clase nueva. El método get viejo armaba su propia request e integraba la lógica de headers/manejo de respuesta directamente adentro; saqué eso hacia un request(req) privado y compartido que tanto get como un nuevo put llaman, así que el camino de escritura obtiene los mismos headers de autenticación, el mismo test seam (un proc connection: que las pruebas intercambian), y el mismo manejo de códigos de respuesta gratis:
def put_file(path, content, message:, sha: nil)
body = { message: message, content: Base64.strict_encode64(content) }
body[:sha] = sha if sha
response = put(contents_path(path), body)
{ sha: response.dig("content", "sha"), commit_sha: response.dig("commit", "sha") }
end
La parte interesante fue el mapeo de errores. La documentación de GitHub es vaga sobre exactamente qué código de estado significa “el sha está desactualizado” versus “la solicitud estaba mal formada”, ambos casos caen bajo el paraguas general de “esto no funcionó.” Mapeé 409 a un nuevo ConflictError, distinto del Error genérico que ya tenía el cliente, y dejé 422 bajo el error genérico. El razonamiento: 409 es el caso real de bloqueo optimista, alguien más hizo commit después de que se leyera el archivo, y la respuesta correcta en la interfaz es “recargar y dejar que el usuario reintente.” 422 significa que algo está mal en la solicitud misma (falta el sha en una actualización, contenido inválido), reintentar con el mismo sha no va a arreglar eso, así que decirle al editor “recargar desde el repo” sería directamente engañoso.
Decisiones que tomé y por qué
Serialización de escrituras, pospuesta. El alcance original del issue incluía serializar las escrituras por blog para que GitHub no viera commits concurrentes al mismo repo. No lo construí. Todavía no hay quien la llame, ni editor, ni job, nada que invoque put_file en producción, y un mecanismo de serialización solo tiene sentido una vez que se conoce el patrón de invocación (job asíncrono versus escritura síncrona desde el controlador). Las tablas de Solid Queue de blog-manager ya tienen solid_queue_semaphores migrada y sin usar, así que limits_concurrency está ahí, lista para el job futuro que la necesite. Construirla ahora habría sido adivinar una interfaz para un llamador que no existe. Además: un solo desarrollador, un solo proceso worker, las escrituras concurrentes al mismo blog son casi físicamente imposibles por ahora de todos modos.
Extender, no duplicar. Podría haber escrito una clase paralela Github::ContentWriter. No lo hice, porque habría necesitado reimplementar exactamente la misma plomería de auth/conexión/mapeo de errores que el cliente de lectura ya tiene, solo para mantener “lectura” y “escritura” separados conceptualmente. Una clase, un conjunto de headers, un test seam.
Lo que me sorprendió
El issue hacía referencia a un archivo de PRD, con IDs de requisitos específicos, que no existe en ningún lugar del repo. El PRD real lista explícitamente “editar el contenido de los posts del blog dentro de la app” como un no-objetivo. Eso no es tanto una contradicción como evidencia de que la dirección cambió desde que se escribió ese PRD, y el issue simplemente no recibió la actualización correspondiente en el rastro documental. Lo señalé y seguí adelante en vez de tratarlo como un bloqueo, el issue en sí era lo bastante específico como para construir a partir de él.
Sorpresa más grande: mientras perseguía el trabajo pendiente de un issue relacionado sobre el mismo archivo de documentación, encontré una rama local con ocho días y unos veinte issues mergeados de atraso, con un stash encima. Al comparar el diff contra el main actual aparecieron ~120 archivos y miles de líneas de desvío, si se hubiera mergeado tal cual habría revertido una buena parte de trabajo ya publicado. Pero el stash encima de esa rama, un diff pequeño y limpio, solo de documentación, aplicó limpio contra el main actual sin ningún conflicto, porque un stash no está atado a la ascendencia de commits de la rama de la misma forma que la rama misma. Vale la pena recordarlo: una rama vieja y el stash que tiene encima no son el mismo artefacto, y el segundo puede seguir valiendo la pena rescatarlo incluso cuando el primero hay que descartarlo.
Sorpresa más chica: pasé el diff por una revisión automatizada de 8 ángulos antes de mergear (corrección línea por línea, auditoría de comportamiento eliminado, rastreo de llamadores entre archivos, más chequeos de reutilización/simplificación/eficiencia/altitud/convenciones). Los ángulos de corrección salieron limpios, el refactor preservaba el comportamiento. Pero atrapó dos cosas reales que había introducido sin darme cuenta: había usado guiones largos en la nueva sección de documentación (una regla dura de “nunca” que tengo para toda mi propia escritura), y un docstring en put_file que solo repetía su propio valor de retorno una línea arriba del código que ya lo mostraba. Tres de los ocho ángulos convergieron de forma independiente en la misma pequeña duplicación, el template de ruta de la Contents API armado por separado en tres métodos, algo que valía la pena arreglar precisamente porque tres lentes sin relación llegaron ahí de forma independiente.
Qué sigue
La interfaz del editor en sí, y lo que sea que invoque put_file en producción, es trabajo aparte, futuro, junto con la decisión de serialización que va a forzar. También sigue abierto: los PATs de blog existentes tienen alcance de solo lectura; el token de cada blog necesita que se le reasigne el alcance a Contents: Read and write en la interfaz de GitHub antes de que una escritura funcione de verdad contra ese repo. No hay API para eso, es un paso manual por blog, para cuando el editor esté listo para usarlo.
Lecturas relacionadas
Escaneo de posts sobre la API de GitHub: qué significa realmente el mínimo permiso
Construyendo el motor de sincronización de posts para blog-manager. Net::HTTP, PATs de grano fino, y una búsqueda dentro de __NEXT_DATA__ para verificar la respuesta.
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.