A daily GitHub → local LLM pipeline that writes my project cards
The Projects section on this site isn't hand-maintained. Once a day a job looks at my GitHub repositories, notices what changed, asks a local language model to write a short description of each changed project, and saves the result. I publish the ones worth showing; the rest sit as drafts.
Here's how it's wired — and, more importantly, the three rules that keep it from doing anything stupid.
The flow
cron (03:30)
→ GET /api/internal/repos (what I already know: repo → last SHA)
→ GET api.github.com/user/repos (fine-grained, READ-ONLY token)
→ diff by default-branch SHA (only changed repos go further)
→ fetch README + languages
→ POST {OLLAMA}/api/chat (gemma, format: json, think: false)
→ POST /api/internal/projects (merge into SQLite)
The LLM is local — an Ollama instance running Gemma on the LAN. No code, README, or repo metadata ever leaves the network. The model is asked, in a strict system prompt, to behave like a "precise technical copywriter" and to return only a JSON object:
{ "title": "…", "blurb": "…", "tags": ["…"], "highlight": true }
Ollama's format: "json" guarantees parseable output, and think: false
disables the model's reasoning trace — faster (~5–6 s/repo) and cleaner.
Rule 1 — only touch what changed
Every repo I've seen is recorded in a repo_state table with its last
default-branch SHA. Each run compares the live SHA to the stored one; unchanged
repos are skipped before the model is ever called. The LLM is the expensive
step, so it only runs for repos that actually moved. A second run minutes later
does nothing and calls the model zero times.
Rule 2 — never overwrite my edits
The model's output is auto data. In SQLite, every project has two parallel sets of fields: the auto values (from GitHub/LLM) and the override values (what I typed in the admin). The public site shows the override if it exists, otherwise the auto value.
The merge endpoint enforces this. When a repo changes and the pipeline posts new
data, it updates the auto columns and leaves *_override, published, and
sort_order untouched. So I can rewrite a blurb by hand, and tomorrow's run
will refresh the GitHub metadata underneath it without ever touching my words.
The LLM is a suggestion engine, not the source of truth.
Rule 3 — private stays private
All my source repos are private, and a private README can contain client or
internal context I'd never want on a public page. So the import is
confidential by default: a newly discovered repo is inserted as an
unpublished draft (published = 0). Nothing the pipeline creates is visible
on the site until I explicitly flip the switch in the admin. The token itself is
read-only (Contents + Metadata, no write scopes) — it can look, it can't
push.
When the model is down
Because the LLM sits in this offline batch path and not in the request path, an outage is a non-event for visitors — the site keeps serving from SQLite. The pipeline handles it gracefully too: if Ollama is unreachable, that repo is skipped (its SHA isn't advanced, so it's retried next run) and I get a single Telegram alert listing what was skipped. No crashes, no half-written data, no silent gaps.
Why bother
I ship small tools constantly. Keeping a portfolio in sync by hand is exactly the kind of chore that quietly rots. This pushes the boring part — "what did I build, and what's a one-line description" — onto a machine that runs every night, while keeping every editorial and privacy decision firmly with me.
The interesting automation never sits between you and this page. That's the whole design.
Transparency: most posts here are drafted with AI assistance, and the non-English versions are machine-translated by a local LLM and then reviewed. Spotted an error? Please let me know.