Skip to content
Development

Teaching blog-manager to write, not just read, GitHub repos

By Victor Da Luz
railsrubygithub-apidev-logblog-manager

blog-manager has been read-only against blog repos since the first version: scan a repo, parse frontmatter, syndicate to Medium and Dev.to. Every write happened in a browser, by hand, in the repo itself. This was the first issue that asked the app to commit something back.

What I was trying to do

The scope was narrow on purpose: not the editor, just the primitive underneath it. A method that takes a path, some content, and a commit message, and turns it into a PUT /repos/{owner}/{repo}/contents/{path} call - creating a file if it’s new, updating it if you pass the blob’s current sha. The editor that actually calls this is a separate, later piece of work.

What I built

There’s already a Github::ContentClient - a small hand-rolled Net::HTTP wrapper, no Octokit, that the post scanner and syndication code use for GET. I extended it instead of writing a new class. The old get method built its own request and inlined the header/response-handling logic; I pulled that into a shared private request(req) that both get and a new put call, so the write path gets identical auth headers, the same test seam (a connection: proc the tests swap in), and the same response-code handling for free:

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

The interesting part was error mapping. GitHub’s docs are vague about exactly which status code means “your sha is stale” versus “your request was malformed” - both live under the general umbrella of “this didn’t work.” I mapped 409 to a new ConflictError, distinct from the client’s existing generic Error, and left 422 under the generic error. The reasoning: 409 is the actual optimistic-lock case - someone else committed since you read the file, and the right UI response is “reload and let the user retry.” 422 means something’s wrong with the request itself (missing sha on an update, bad content) - retrying with the same sha won’t fix that, so telling the editor “reload from repo” would be actively misleading.

Decisions I made and why

Deferred write serialization. The original issue scope included serializing writes per blog so GitHub doesn’t see concurrent commits to the same repo. I didn’t build it. There’s no caller yet - no editor, no job, nothing invoking put_file in production - and a serialization mechanism only makes sense once you know the invocation pattern (async job vs. synchronous controller write). blog-manager’s Solid Queue tables already have solid_queue_semaphores migrated and unused, so limits_concurrency is sitting there ready for whichever future job needs it. Building it now would have been guessing at an interface for a caller that doesn’t exist. Also: single developer, single worker process - concurrent writes to the same blog are close to physically impossible right now anyway.

Extend, don’t duplicate. I could have written a parallel Github::ContentWriter class. I didn’t, because it would have needed to reimplement the exact same auth/connection/error-mapping plumbing the read client already has, just to keep “read” and “write” conceptually separate. One class, one set of headers, one test seam.

What surprised me

The issue referenced a PRD file - with specific requirement IDs - that doesn’t exist anywhere in the repo. The actual PRD explicitly lists “editing blog post content inside the app” as a non-goal. That’s not a contradiction so much as evidence the direction changed since that PRD was written, and the issue just didn’t get the paper trail updated to match. I flagged it and kept going rather than treating it as a blocker - the issue itself was specific enough to build from.

Bigger surprise: while chasing down a related issue’s leftover work on the same doc file, I found a local branch eight days and about twenty merged issues stale, sitting there with a stash on top of it. Diffing it against current main showed ~120 files and thousands of lines of drift - it would have reverted a chunk of already-shipped work if merged as-is. But the stash on top of it - a small, clean doc-only diff - applied cleanly to current main with zero conflicts, because a stash isn’t tied to the branch’s commit ancestry the way a branch itself is. Worth remembering: a stale branch and the stash sitting on it are not the same artifact, and the second one can still be worth salvaging even when the first one has to be thrown out.

Smaller surprise: I ran the diff through an automated 8-angle review pass before merging (line-by-line correctness, removed-behavior audit, cross-file caller tracing, plus reuse/simplification/efficiency/altitude/conventions checks). The correctness angles came back clean - the refactor was behavior-preserving. But it caught two real things I’d introduced without noticing: I’d used em dashes in the new docs section (a hard “never” rule I have for all my own writing), and a docstring on put_file that just restated its own return value one line above the code that already showed it. Three of the eight angles independently converged on the same small duplication - the Contents API path template built separately in three methods - which was worth fixing precisely because three unrelated lenses landed on it independently.

What’s next

The actual editor UI and whatever invokes put_file in production is separate, future work - along with the serialization decision it’ll force. Also still open: existing blog PATs are read-only-scoped; each blog’s token needs re-scoping to Contents: Read and write in GitHub’s UI before a write will actually succeed against that repo. There’s no API for that - it’s a manual step per blog, whenever the editor is ready to use it.

Related reading