Yesterday I promised more about the small thing I built while doing something else. Here it is.
The problem was bookkeeping, not code
Two weeks of packaging work left me with eleven open items on other people’s infrastructure: pull requests to YunoHost and to Lexicon, bug reports to Debian and ProcessOne, a catalog submission, plus my own tracker. Three systems that don’t talk to each other, and no notion of “since I last looked.”
So every morning I was opening tabs, reading threads I’d already read, and trying to remember yesterday’s state. It doesn’t scale, and worse, it silently doesn’t scale: a maintainer answered one of my reports at 05:50 and nothing told me. I found it hours later, by accident, after I’d already posted a follow-up that would have been written differently if I’d seen his reply first. That’s the failure mode — not “I have too many tabs”, but “I acted on stale information without knowing it.”
Does this already exist?
The rule I try to keep: search before you build, and be honest about what you find.
The closest thing is bugwarrior, which pulls GitHub, GitLab, Debian’s BTS, Pagure, Bugzilla, Trac and Jira into Taskwarrior. It’s good, it’s maintained, and if I lived in Taskwarrior I’d have stopped there. But it has no Forgejo support (only an open PR), and its model is import issues as tasks rather than tell me what changed. Beyond that it’s one tool per silo: gh-dash and Octobox for GitHub, tea for Forgejo, bts for Debian.
Nothing spans the set, and nothing does deltas. So: build it, but keep it small.
The one design decision that made it pleasant
Every watcher needs to remember what it saw last time. The obvious approach is a state file — some JSON cache next to the data, which you then have to keep in sync with the data.
Instead: the snapshot is the state. The tool writes a GNU recutils database of every watched item — reference, source, title, state, last update, comment count — and the next run parses that to compute what changed, before overwriting it wholesale.
Three things fall out for free:
- No cache to desynchronise. There is exactly one file, and it’s the same file you’d look at yourself.
- It’s queryable.
recsel upstream.rec -e "Project = 'ejabberd' && State = 'open'"answers questions the tool was never designed to answer. - git is the history.
git log -p upstream.recis now a record of what the outside world did to my open items, forever, without anybody designing a history feature.
That last one still delights me. The changelog of other people’s decisions, as a side effect of committing a file.
What the reviews caught
I built this with Claude, one task at a time, each with an independent review pass before moving on. Two bugs found this way came from my own implementation plan — the plan contained the buggy code, and the implementer faithfully wrote what it was told:
- The summary line counted items that had left the watchlist as “moved”, so removing three items from a list of zero printed
0 watched · 3 moved · -3 unchanged. Nonsense arithmetic that I’d have shipped. - The plan simultaneously required “the round trip must be lossless” and specified code that collapses whitespace. Both were reasonable; together they were a contradiction, and only a reviewer reading them side by side noticed.
Then the final review — the first one to see the whole thing rather than one task — found the real bug:
Debian bug reports arrive as email. Email headers fold across lines. A long From: header contains a newline, that value goes into the database as LastBy:, and a newline mid-value writes a bare continuation line into a recutils file. recsel then rejects the entire file — not the record, the file. Meanwhile my own parser, being lenient, silently truncated the value and carried on. The tool would have kept reporting cheerfully while the artifact rotted.
It hadn’t fired yet only because the one Debian bug on my watchlist happens to have a short From: header.
Two lessons, both worth more than the bug:
- Your data contract is defined by your strictest consumer, not your own parser. I wrote both the writer and the reader, so they agreed with each other — and were both wrong about what a recfile is.
- A lenient parser hides corruption. Being forgiving on read felt like robustness. It was camouflage.
Deliberately not a new repository
The tempting move was a new repo: nice name, own README, room to grow. I talked myself out of it and wrote the reasoning into the ticket, with an explicit trigger for changing my mind — if I publish it, or if it grows its own scheduler, extract it. Until then it’s a second script next to the one that already does exactly this shape of work.
In a personal ecosystem, a repo isn’t free: it’s a catalogue entry, a task-manager project, a config block, eight mandatory files. For four hundred lines of code that already have a natural home, that’s a bad trade. The trigger matters more than the decision, though — a decision without a revisit condition is just a preference.
In use
It only speaks when something happened. Most runs are one line:
12 watched · nothing moved
And when something does move, it says what, who, and when:
## Upstream deltas — 2026-08-07 08:27 UTC
### YunoHost/apps#3573 — Add ejabberd (ours: #263)
- comments: 3 → 4
- TabTwo (2026-08-07): "Thanks @Thovi98 — all fair, and thanks @ericgaspar for the invite…"
12 watched · 2 moved · 10 unchanged
A footnote from the first live run, for anyone building against Forgejo: a Forgejo token’s scopes are fixed when you create it. My existing token could list repositories but not read issues, and no amount of configuration widens it — you make a second token. An hour of confusion, one line in the docs, written down so the next person doesn’t lose the same hour.
Was it worth it?
Half a day, against a chore of maybe ten minutes a morning that I was doing badly. On pure arithmetic, arguable. But the thing I actually bought wasn’t the ten minutes: it’s that I no longer have to trust my memory about what other people said. And the failure it prevents — replying to someone who already answered — costs more than time.
It also stayed small enough that I could delete it tomorrow. That’s the property I’d defend hardest.