githoot

The PR page

The three PR menu entries open a page GitHoot renders and serves itself, on loopback, listing exactly the pull requests that bar counted.

They used to open GitHub. That worked for one of the three and not the other two, because no GitHub search URL can express what those bars count — review:approved misses every approval in a repository that requires no reviews, and review:changes_requested keeps matching a pull request you have already handed back (→ PR status). So those two entries opened one browser tab per pull request instead, and fell back to a hand-built search URL that claimed to be “the same query” — which it was not, since the bars narrow their hits after the search, and which did not work anyway. Every link out now goes to GitHub’s own pull-request inbox, which is honest about being a different view and is always reachable.

The page shows what the bar counted and what GitHub’s list could not: the check rollup, and every reviewer’s standing verdict.

   
Address http://githoot.localhost:<port>/<token>/<bar>, plus /items, and the settings pages
Bound On the first click, never at startup, unless localApi is on
Port and token New on every run
Contents Title, repo, number, author, age, draft, checks, merge conflict, open comments from the portal’s automatic reviewer (Copilot on GitHub), per-reviewer verdicts

Grouped by portal. With one configured portal the page is a bare list. With several, each portal’s cards sit under a heading with their own empty state and their own inbox link, because “nothing here” on one forge says nothing about another. Only URLs under a portal’s own link prefix become links (https://github.com/ for GitHub, trailing slash included), so a URL that is not the portal’s own is shown as text rather than offered as a click → Portals.

Newest first. GitHub’s search answers in best match relevance order whenever the query names no sort, and none of the three does, so what comes back is effectively arbitrary and can differ between two polls over the same pull requests. The page sorts by last update instead, so the list does not shuffle under you between reloads. A pull request GitHub gave no date sorts last.

Links open in the same window. Opening a tab per click is the habit this page exists to get away from; the back button is the way back to the list.

It keeps itself current, without a reload and without touching GitHub — so a change is on screen within about five seconds of the poll that found it.

Every five seconds the page asks /<token>/<bar>/items whether anything has moved, as a conditional request. Each answer carries an ETag — the version of the snapshot the poll loop last published — and the page sends it back as If-None-Match. Between polls the reply is a bodyless 304, which is a few dozen bytes; only a poll that actually published something returns a list, and only then is the list on screen replaced. Rebuilding it otherwise would drop hover, focus and any text selection inside it, for rows that have not moved.

The age is computed by the page, not sent by the server, and that is what makes the 304 honest. “as of 47 s ago” changes every second, so a response carrying it could never be unchanged and the server would have to resend the whole list every few seconds to keep one line current. Instead each answer says how old the data was at that instant, the page anchors a local clock to it, and the line ticks on with no request at all.

Three things keep a forgotten tab cheap: it pauses while the tab is hidden and catches up the moment it is shown, it never reaches GitHub, and it stops after five consecutive failures and says GitHoot is not running — otherwise a tab left open would poll a dead port for as long as it lived.

This is the one place GitHoot serves anything executable. The script is a dozen lines, and the page’s CSP names it by nonce rather than allowing inline script at large: a fresh random value per response, so only that exact <script> may run and anything injected into the page cannot borrow the permission. connect-src 'self' is opened alongside it, because default-src 'none' would otherwise block the fetch. Everything else GitHoot serves still runs nothing at all.

It never shows a zero it cannot stand behind. A bar whose poll failed, or that has not answered yet, gets a page saying the list is not known — not an empty one. The same refusal the tray makes about a stale count in a menu label.

Why githoot.localhost

.localhost is reserved by RFC 6761 and browsers resolve every subdomain of it to loopback on their own. No hosts file, no DNS, no admin rights.

A bare githoot/prlist is not achievable and was not a near miss: a single-label name resolves only via the system hosts file, which needs root, and a URL with no port means port 80, which needs root on Linux and macOS and is usually held by http.sys on Windows. The token would still sit in the path, so the tidy part would not have survived anyway.

GitHoot listens on 127.0.0.1 and [::1] on the same port, because a browser may resolve the name to ::1 first. If the IPv6 bind fails the IPv4 one carries on alone and browsers fall back.

Why a server and not a file

An HTML file in ~/.githoot/ would be the looser option, not the tighter one. It persists after the app exits, it is readable by anything running as you and by every browser profile on the machine, and it is stale the moment the next poll lands. A render per request cannot go stale, and a token that dies with the process bounds what a leaked URL is worth.

What guards it

A listening socket on your machine is worth being precise about. Four things stand in front of the page, and each one stops something the others do not.

Two more things about what leaves the page:

Only GET, HEAD and — on the settings route alone — POST are answered. There is no keep-alive, the request head is capped at 8 KiB and a form body at 64 KiB, and both have a five-second timeout, so a client that connects and says nothing cannot wedge the listener.

Muting a pull request

Every row offers “Mute for 3 days · 7 days · 30 days”. A muted pull request drops out of its bar: it does not light the icon, does not count on the menu entry or the page, and does not hoot. It is not hidden. It moves to a Muted section at the bottom of the same page, saying how long is left and offering Unmute.

Every muted pull request is on one page too, /<token>/muted, the Muted tab on every one of GitHoot’s own pages, and linked from the Muted heading of any bar. It is the only way to a muted pull request whose bar is otherwise empty, because an empty bar’s menu entry is hidden.

A muted pull request that leaves every bar keeps its title and link. Merged, closed or simply no longer waiting on you, it is listed under No longer in any bar as it was when you muted it, marked Gone from your bars, with how long until it is removed. It is removed 7 days after it left, or when its mute ends if that is sooner. It is only marked gone after a poll in which every bar of its portal answered, so a failed poll never counts as leaving, and if it comes back first it is simply muted again. Mutes from before this change have no title and are still listed by id.

It comes back as a new pull request. When the mute ends, by the link or by time, the pull request arrives as if never seen: the bar lights and the owl hoots. A mute is a snooze, and a snooze that ended in silence would be a way to lose a review.

Mutes are kept on disk, in ~/.githoot/muted.txt, one pull request per line, because a mute measured in days has to survive the restarts a self-update causes. A line is the key, when the mute ends, when it left every bar, its portal, its link and its title, separated by tabs. Only the first two are required. Expired lines are dropped whenever the file is read or written. The links are forms, not links, so a mute is a POST behind the same Origin check as the settings pages, and it accepts only the three offered durations and only a pull request that is on the page right now. The icon catches up within seconds, because a mute wakes the poll loop.

The local API still serves muted pull requests, marked "muted": true, and the dispatcher skips them.

When it cannot start

If the loopback bind fails — a hardened container, a sandbox, a machine with no loopback — the menu entries open GitHub’s own pull-request inbox instead. The failure is logged once, with the operating system’s own error, and not retried, since nothing that causes it clears between two clicks.

The listener stops when GitHoot does. There is nothing to clean up, and nothing is written to disk.

One setting changes both halves of that sentence. With localApi on, the listener binds at startup rather than on a click, and the port and token are written to ~/.githoot/endpoint.json so a script can find them. It ships off, and off it changes nothing here.