Watching nixpkgs PRs: A Noctalia Plugin to Tell Me When a Fix Lands

On a rolling NixOS system, “the fix exists” and “the fix is on my machine” are two very different events — and the gap between them is a pull request crawling through nixpkgs, from master to staging to, eventually, nixos-unstable. This is the story of getting tired of refreshing a webpage to watch that crawl, and building a small tool to do the watching for me.

The bug that started it

My desktop config (noctix-os) uses ReGreet, a GTK4 Wayland login greeter running in cage. One rebuild later, my login screen went into a crash loop — ReGreet would start, die, get restarted, and die again, leaving me staring at a flickering, unusable boot instead of a password prompt.

The cause turned out to be a nicely-tangled little regression. ReGreet had added a GStreamer-backed feature for animated wallpaper playback, but somewhere along the way it got switched on for every media type rather than just animated ones — so ReGreet now reached for a GStreamer element (playbin3) that wasn’t in its closure. The practical result: if any wallpaper was set, the greeter crashed on startup — and because it’s a supervised service, crashing just meant it came back to crash again. On a machine whose entire reason for existing is a themed, wallpaper-driven desktop, that was a locked front door.

The good news: the fix wasn’t a mystery. Someone had already opened PR #530302 — “ReGreet: add missing GStreamer dependencies” against nixpkgs, adding the missing GStreamer deps. It got reviewed, approved by three people, and merged into master on June 16, 2026.

And that’s where the real problem started — because merged-to-master is not the same as on-my-machine.

The limbo between “merged” and “installed”

My system tracks nixos-unstable, and a merged PR doesn’t reach that branch immediately. It lands on master, flows through staging, gets built by Hydra, and only shows up on nixos-unstable once the whole downstream rebuild has gone green. That can take hours or days — and until it does, my fix is real, merged, and completely useless to me.

So I was left doing the thing every rolling-release user eventually does: opening nixpk.gs, typing in 530302, and squinting at a tree of branch names to see whether nixos-unstable had finally turned green. Then closing the tab. Then reopening it an hour later. For days. (As I write this, I’m still waiting on it — which is the most honest possible endorsement of why I needed the tool.)

That’s a polling loop a human should never be running.

What I actually wanted

I run Noctalia as my desktop shell — bar, launcher, notifications, the works. It has a v5 plugin system, which meant the tool I wanted could live exactly where my attention already was, instead of being one more terminal script I’d forget I’d written. The spec was small:

  • Give it a list of PR numbers and a target branch (nixos-unstable, for me).
  • Have it check, in the background, whether each PR has reached that branch.
  • Fire exactly one desktop notification per PR the moment it lands — never a duplicate, even after a restart.
  • Optionally, a tiny bar widget showing a merged/total count so I can glance at progress.

That became noctalia-nixos-tracker.

The first wall: there is no API

The obvious plan was to hit some JSON endpoint and read a status field. That plan died fast.

nixpk.gs is Alyssa Ross’s pr-tracker, and it has no JSON API. A request to pr-tracker.html?pr=<N> returns fully server-side-rendered HTML — no client-side JavaScript, no XHR call to intercept, nothing structured to grab. The branch status I wanted was sitting inside the rendered markup and nowhere else.

So the tracker parses the HTML directly. Each branch in the tree renders as a node that looks roughly like this:

<span class="state-accepted">✅</span>
<a href="https://hydra.nixos.org/...">nixos-unstable</a>

The rule I settled on: a PR has “reached branch X” when the anchor whose text is X is paired with a state-accepted class. The other states — pending, unknown, rejected (closed unmerged) — all mean “not landed,” and each needs to be handled without crashing the poll.

Parsing someone else’s HTML is inherently fragile; their markup can change and quietly break you. So I pinned down the behavior with captured fixtures: real saved nixpk.gs responses in a samples/ directory that the parser is tested against. If the parse logic ever regresses, a sample catches it before I ship.

Architecture: service vs. widget

The biggest design decision was splitting the plugin into two pieces, mirroring Noctalia’s own screen_recorder plugin:

EntryFileRole
Servicebackground.luauA headless [[service]] that runs the whole session. It polls, parses, notifies, and publishes a status snapshot to a shared state channel.
Widgetwidget.luauA thin bar tile that mirrors status and can trigger a refresh on click.

The reason this split matters: the polling lives in the service, not the widget. That means notifications fire whether or not the widget is actually placed on my bar. The watching is the real job; the tile is just a window into it. Tying the poll loop to a visible UI element would have been the easy mistake, and it would have meant the tool only worked when I happened to be displaying it.

The widget talks back to the service over a command channel to force an immediate refresh — that’s what a left-click does.

The nixos-tracker bar widget showing a 0/1 merged count, with its tooltip listing PR #530302 as still pending on nixos-unstable, and a "click to refresh" hint.

The widget in action: a 0/1 count pinned to the Noctalia bar, its tooltip showing PR #530302 still pending on nixos-unstable — the very fix this whole project is waiting on.

Notify-once, and why it’s harder than it sounds

“Send one notification when the PR lands” sounds trivial. The trap is state. A poll loop has no memory; every 10 minutes it wakes up, sees the PR is merged, and — if you’re naive about it — fires a notification. Every. Single. Time. You’d get pinged forever about a thing that landed last Tuesday.

So each notification is keyed by PR@branch and persisted to disk:

${XDG_STATE_HOME:-~/.local/state}/noctalia/nixos-tracker/notified.json

Once a PR@branch key is written, that pairing never notifies again — across reloads, across restarts, across reboots. A few consequences fall out of that design that I had to handle deliberately:

  • Add an already-merged PR and you get exactly one notification on the first poll, then silence. (You asked it to watch something that already happened; it tells you once, then shuts up.)
  • Change the target branch and notifications re-arm for the new branch — but the old history is kept, so switching back doesn’t re-fire stale pings.

Living inside the Nix store

This is the part that’s specifically a NixOS problem, and it’s the detail I’m proudest of getting right.

When this plugin is deployed declaratively — as a flake input symlinked into Noctalia’s plugin directory via home-manager — its own files live in the Nix store, which is read-only. A plugin that tried to write its state file next to its own code would work fine when copied in by hand and then mysteriously break the moment someone installed it “the right way.”

So the rule is absolute: the plugin never writes into its own directory. All runtime state goes to $XDG_STATE_HOME. A read-only store path is a fully supported, first-class way to run it. The home-manager install is genuinely just:

inputs.noctalia-nixos-tracker = {
  url = "github:1-bit-wonder/noctalia-nixos-tracker";
  flake = false;
};

# ...then, in your home config:
xdg.dataFile."noctalia/plugins/nixos-tracker".source = inputs.noctalia-nixos-tracker;

Designing for the read-only case from the start is the difference between “works on my machine” and “works on a stranger’s declaratively-managed machine they rebuilt from scratch.”

Failure is not allowed to be fatal

The tracker is talking to a third-party website on a timer, forever. Things will go wrong: the network drops, nixpk.gs returns something unexpected, a PR number is wrong and 404s, a PR gets closed unmerged. None of those are allowed to take down the poll loop. Every one is caught, logged, and surfaced as a state — the widget shows a ⚠ icon and the tooltip explains which PR errored — but the service keeps running. A tool whose entire purpose is to watch something over a long period fails its one job the instant an error can kill it.

Verifying it

Because the whole thing hinges on parsing real-world HTML and firing the right number of notifications, I verified it against reality rather than vibes. With luau 0.720, against the real nixpk.gs response and the real Noctalia plugin API:

  • Both scripts compile and run clean under a mocked host.
  • The parser extracts the PR title and every branch state correctly from the saved sample.
  • End-to-end, a real sample fires exactly one notification, dedups across repeated polls, treats a 404 as an error rather than a crash, and publishes the correct merged/total/errors snapshot.

What I took away from it

This was a small plugin, but it’s a clean little study in the parts of systems work that don’t show up in a feature list:

  • You don’t always get an API. Sometimes the data source is rendered HTML and your job is to parse it carefully and pin it down with fixtures so it doesn’t rot.
  • State is the hard part. “Notify once” is a persistence-and-dedup problem wearing a trivial-looking costume.
  • The deployment target shapes the design. The read-only Nix store isn’t an edge case to patch around later — it’s a constraint that, taken seriously up front, makes the tool correct everywhere instead of just on the machine it was born on.
  • Long-running means failure-tolerant. Anything that polls forever has to treat every error as routine.

The irony isn’t lost on me that I built a tool to watch a pull request because of a bug, and the tool itself is now the most interesting thing to come out of that bug. That’s usually how it goes.

If you’re on NixOS and tired of refreshing nixpk.gs by hand, the plugin is here. And the desktop it lives in — Hyprland, Noctalia, the greeter and all — is noctix-os.

all posts