← Blog

A daily GitHub → local LLM pipeline that writes my project cards

DEENESRUUK

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.