Case study · Internal tooling at a mid-size media company
The merge that files its own paperwork
A five-minute poller that watches for my own pull requests being merged and then does everything that used to follow by hand — moving the linked tickets, opening the build that will ship them, and coming back later for the ones CI has not started yet.
- Pull requests handled
- 67
- Measured — in the last 30 days.
- Saved per merge
- ~10 min
- Estimated — my own account of the manual steps it replaces, not a timed measurement. My time only.
- Running unattended
- since 8 May 2026
- Structural — every five minutes, no intervention.
- Retrospectives written unprompted
- 22
- Measured — since 14 July 2026, when this half was switched on. It is the newest part of the tool.
The problem
Merging is not finishing
Every pull request I open is linked to at least one ticket, and merging it finishes none of that. Each linked ticket still has to be moved to its final state. The build that actually ships the change has to be found and opened, so that I notice when it fails. And a retrospective on the work — what the spec missed, what I only discovered halfway through — exists only if I remember to write one, which I did not.
None of this is difficult. All of it is required. And all of it comes due at the exact moment I have stopped thinking about that piece of work and started on the next one. That is the shape of every chore worth deleting: trivially easy, strictly mandatory, and demanded of you precisely when your attention has already left the building.
In the last thirty days that was sixty-seven pull requests. At roughly ten minutes of clicking each, that is the better part of two working days spent moving tickets between states and hunting down build pages.
And merging is not shipping. There is a manual step between the two, so a merge I forget about is not an untidy ticket — it is a finished change sitting undeployed. That is what turns a tolerable chore into a real one. The cost of forgetting is not untidiness, it is work that quietly never arrived.
What I built
A script that runs, finishes, and runs again
A single script on a five-minute schedule. Each cycle it asks the issue tracker for pull requests I authored that have completed since the last time it looked, and for each one it:
- reads the tickets linked to the pull request and moves each to its terminal state, leaving alone any that are already there;
- finds the build the merge triggered and opens it, so a failure is something I see rather than something I discover;
- if that build has not been queued yet, writes the pull request to a pending list and comes back for it on a later cycle;
- optionally launches a detached, budget-capped headless agent run per linked ticket, which writes the retrospective I would not have written.
Then it records where it got to and exits. There is no daemon and no service. It runs, it finishes, and five minutes later it runs again.
The only time it speaks to me is a toast notification, and there are exactly two kinds. One says a retrospective is ready: I click it, read what the work taught me, and push anything worth keeping up into the tooling so it stops being something I have to remember. The other says something failed. Silence means everything landed, which is the only sensible default for a thing that runs three hundred times a day.
Worth being straight about the seam between those two halves. The merge paperwork has run since May and is boring, in the way a thing you have stopped thinking about is boring. The automatic retrospective was switched on in July and is twenty-two pull requests old. It is the part I would defend least if you pushed on it today, and I would rather say that than let a two-month-old number and a one-week-old number sit next to each other pretending to be the same kind of claim.
Architecture
The shape of one cycle
The hard part
Safe to run forever
Automating this once is an afternoon. Automating it so that it is safe to run every five minutes, unattended, indefinitely, is the actual work — and almost all of that work is in the unglamorous parts.
I should be straight about what “hard” means here, because the honest answer is not a war story. Nothing about this took weeks to get right, and it has run since May without an incident worth the name. The difficulty was front-loaded: deciding which failures a thing on a timer is certain to meet eventually, and refusing to ship until each one was already handled. What follows are not scars. They are the reason there aren’t any.
Doing nothing, twice
A script that runs once is allowed to assume it is the first one. A script on a schedule never is. It will see the same merged pull request again after a retry, after a crash halfway through, and on the day I run it manually to see what it does — and every one of those is a chance to transition a ticket that was already transitioned, or to fire a second retrospective at something that already has one.
Three guards do that work. Handled pull requests are deduplicated by id, and that record survives a forced re-scan — the manual escape hatch is the single most likely thing to reprocess a merge that was already dealt with, so it is the case worth designing for rather than the case worth assuming away. Ticket transitions check for a terminal state first, so a ticket someone has already closed by hand is left alone rather than dragged through its workflow a second time. And the retrospective is fired detached, with a budget and a turn cap, so the one component whose runtime I genuinely cannot predict is also the one component that can never block the poll or run away with itself.
None of that is visible when the tool works, which is exactly the point. An automation you have to supervise is not an automation, it is a second job.
The build that does not exist yet
One thing did catch me out, briefly. Completion and CI are not simultaneous, so a poll that lands in the gap sees a merged pull request with no build attached to it. Diagnosing that took a few minutes; what is worth keeping is the shape of the fix, because the two tempting answers are both wrong. Giving up on the pull request means the merges most worth watching — the ones where CI was busy — are precisely the ones that silently never get opened. Waiting for the build means a five-minute poller sits in a sleep loop, holding the next cycle hostage to the slowest thing in the queue.
So the run stopped being the unit of work. A pull request whose build has not appeared is written to a pending list on disk and the run ends normally; the next cycle reads that list first and retries it. The unit of work became “this pull request, across however many runs it takes” — which is the only unit that survives contact with a system whose timing you do not control.
Limitations
What I’d do differently
Polling has a floor. It costs a fixed latency — up to five minutes between merging and anything happening — and it spends nearly three hundred runs a day learning that nothing has changed. An event subscription would be cheaper and instant, but it needs somewhere to receive events, and that is a service to run rather than a script to schedule. For one person’s pull requests, polling is the right trade. For a team’s, it is not.
It is also built for exactly one author: me. Nothing in it is shared, so there is nothing to coordinate, and that assumption is load-bearing in more places than I would like.
What it does get right is failing loudly. When something breaks it says so with a toast, rather than writing a line into a log I would have to remember to read — and remembering to read things is the entire class of problem this tool exists to remove.
There is no bug I am sitting on. The next iteration is not a fix, it is a question of distance. A retrospective currently ends as a document I read. The findings worth the most are the ones that should become permanent — a check, a convention, something that makes the mistake structurally unavailable next time — and today the step from “this was written down” to “this cannot happen again” is still me, reading and deciding.
Shortening that distance is the obvious next move, and it belongs to the retrospective side rather than to this handler. That distinction matters more than it sounds: the temptation with a tool that already works is to keep bolting things onto it until it does everything badly. This one notices a merge and routes the consequences. Deciding which lesson has earned the right to become a permanent rule is a different job, and it should live somewhere else.
What I would not give up in the process is the confirmation step. Summarising the findings automatically is worth doing; applying them automatically is not, at least not until something has proven it can tell a durable rule from a one-off irritation better than I can. Proposing is cheap to be wrong about. Applying is not.
Where this applies
The pattern, beyond my own inbox
Any team where finishing a piece of work means touching three systems that already know it is finished has this problem, and mostly does not count it. The cost is not the ten minutes. It is that the ten minutes fall due at the moment of maximum distraction, so they get done late, done wrong, or not done — and the damage is rarely the untidy ticket. It is the change that was finished, reviewed, merged, and then never actually went out, because the last step was manual and nobody was reminded.
The shape of the fix generalizes further than the script does. Find the single moment the systems already agree something happened, and make that the only trigger. Then make every step after it safe to repeat, because the trigger will fire again — on a retry, on a re-scan, on the day someone runs it manually to see what it does. The integration is the easy half. The half that decides whether anyone still trusts it in six months is idempotency, and it is the half that usually gets skipped.