githoot

PR status

This page describes the GitHub portal, the one every install has by default. The seam it sits behind, and how GitLab judges the same three bars, is in Portals.

Three independent signals, each a GitHub Search query against your own pull requests:

Bar Query Then
đź”´ is:pr review-requested:@me state:open draft:false archived:false -label:dependencies -author:app/dependabot -author:app/renovate counted as is
🟢 is:pr author:@me state:open draft:false archived:false a hit counts when its latest reviews hold an APPROVED and no CHANGES_REQUESTED
🟠 is:pr author:@me state:open draft:false archived:false a hit counts when a reviewer’s objection still stands, when it has a merge conflict or failed checks and someone is waiting on it, or when Copilot has unresolved comments on it

Each bar’s own “not a draft” rule (ruleRedSkipDrafts, ruleGreenSkipDrafts, ruleAmberSkipDrafts) is what puts draft:false in its query. Switched off, it drops that qualifier from that bar’s search alone, and drafts are judged by the bar’s other rules like any pull request. On GitLab, green and amber share one list, which then carries drafts, and the bar that still skips them drops them itself.

The green bar reads the reviews, not review:approved. That qualifier looks like “somebody approved this”, and it is not. It is a projection of reviewDecision, GitHub’s verdict on whether the base branch’s review policy is satisfied, and a repository that requires no reviews gets no verdict: reviewDecision is null on every one of its pull requests, approved or not, and review:approved matches none of them. Measured on a repository with no required-review rule: 100 of its last 100 PRs reported null, six of them carrying a real APPROVED review on the head commit. The PR page still showed its green Approved badge, because the page reads the reviews. So the bar does too, over GraphQL: the query above narrows to your open PRs, and each hit is judged by its latestOpinionatedReviews, one verdict per reviewer.

One approver’s yes does not outrank another’s no. A PR with an APPROVED and a standing CHANGES_REQUESTED counts as changes requested, not as approved, which is GitHub’s own precedence and what keeps the two bars from lighting for the same PR. An approval on a commit you have since pushed over still counts: the PR page shows it, and this bar reports news rather than gating a merge.

A pull request in the merge queue is off the green bar (isInMergeQueue). It is already being merged, so a bar asking you to merge it is noise. Unlike the rules under General, this one has no switch: no setting makes “merge what is already merging” useful. It leaves the queue merged, or kicked out and back on whichever bar its state then earns.

The green bar means approved, not mergeable. It used to mean both: through 1.10.0 the bar counted an approved pull request only when its GraphQL statusCheckRollup was SUCCESS, so one red or still-running check darkened it. That hid the thing worth being told, that somebody approved your work, and it also disagreed with the entry beside it. Now failed checks move it to the amber bar instead of hiding it: an approved pull request with red CI is work, and it shows there with its approval on the card. Checks still running hold it back from green, on neither bar, until they pass: it is not good news yet, and it hoots when it lands. Two states are deliberately not “running”, because either could hold an approval back forever: GitHub’s EXPECTED (a required check that never reported) and GitLab’s manual (a pipeline waiting for someone to start a job). A repository with no checks is not held back at all. Whether CI is green is shown beside each pull request on the page the entry opens.

Still not checked: branch protection needing multiple approvals or named reviewers. Merge conflicts used to be on that list, for the lazy-computation reason below; the amber bar reads them now. Also unchanged: the GraphQL page reads at most 100 open PRs of yours, so past that the bar undercounts.

Checks: read and Commit statuses: read were unused for several releases, after the green bar stopped gating on CI. They were kept rather than withdrawn, because narrowing an installed App’s permissions makes every installation owner re-approve. That turned out well: the PR page reads the check rollup again, at no re-approval cost to anyone.

The amber bar means work required from you, and counts four things.

A reviewer’s objection still standing. Re-requesting a review does not dismiss the earlier verdict, so review:changes_requested keeps matching a pull request you have already handed back, and the bar used to stay lit until the reviewer replied. A hit counts only when no re-review is pending from a reviewer who requested changes. Adding a different reviewer does not clear it — the original objection still stands. A pending team request does not clear it either, since a team has no login to match against the blocker; erring that way keeps the bar lit rather than hiding work.

Somebody waiting is a rule of its own, ruleAmberNeedReviewer, and it narrows the next two: a reviewer attached, either a pending request or a standing review. A conflict or a red build blocks that reviewer as surely as their own objection does, and it is yours to fix; on a pull request nobody is attached to it blocks nobody and does not count. Switched off, both count on any pull request of yours. It acts on amber only: an approved pull request always has a reviewer, so green would not change.

A merge conflict. GitHub’s mergeable says CONFLICTING.

Failed checks. The head’s check rollup is FAILURE or ERROR. Approved or not: a requested reviewer looking at a red build is waiting on you just the same. An approved one moves from the green bar to this one, with the approval still shown on its card. Only a definite failure counts: pending checks, no checks at all, a rollup the token may not read, or a state this version does not know do not.

Unresolved Copilot comments. Copilot never approves and never requests changes — it only comments, so latestOpinionatedReviews drops its review entirely and the objection rule above cannot see it at all. Its verdict is the review threads it leaves open, so an open thread started by copilot-pull-request-reviewer counts as work.

Resolved threads do not count, and neither do outdated ones — a thread pointing at a line you have since changed, which GitHub’s own UI hides. The cost is that pushing something unrelated can outdate a comment you never read and the bar goes quiet on it; erring quiet is the direction taken everywhere else here. Only the thread’s first comment is read, so a human replying to Copilot does not make the thread theirs. Capped at the first 20 threads per pull request, undercounting like every other cap.

Switch it off with copilotReviews=off if your team reads Copilot as suggestions rather than work. That also stops the two axes asking GitHub for review threads at all, which is the most expensive part of the query — the other axis never asks, since Copilot’s comments are the PR author’s problem, not its reviewer’s.

Only a definite CONFLICTING counts. GitHub computes mergeable lazily and answers UNKNOWN until something asks; asking is what starts the computation, so a cold poll sees UNKNOWN and the next cycle sees the answer. Treating UNKNOWN as a conflict would light the bar on every freshly pushed pull request and clear it a minute later. Undercounting for one cycle is the cheaper error.

Anything that makes it work takes the pull request off the green bar. The two never light for the same one: a conflict or an open Copilot thread is work before it is news, and the approval is still there to be told about the moment you deal with it.

The query stopped filtering. It was is:pr author:@me review:changes_requested …, which would now hide every conflicted pull request that carries no objection, and mergeable is not a search qualifier. So it asks for every open pull request of yours and decides here. draft:false came with that: a draft is work you already know is unfinished, so neither half is news on one. Before this, a draft carrying a changes-requested review did count. ruleAmberSkipDrafts=off brings exactly that back.

Caps, which undercount rather than overcount: the first 100 matching pull requests, and the first 20 reviews and 20 pending requests within each.

All three bars now read the same GraphQL document. The red bar was a plain total_count off REST Search until 1.17.0 — an exact, uncapped number with no members. The PR page has to name the pull requests behind a bar, which a total never could, so it fetches the hits instead. The query string is handed to GraphQL unchanged, so the count does not move, except that the red bar now shares the 100-hit cap above: past 100 pull requests awaiting your review, the extras are not seen. Undercounting rather than overcounting, and unreachable by the inbox this app exists for.

The check rollup is read again. Each hit’s statusCheckRollup is shown on the PR page, and it decides two things for an approved pull request: a definite failure moves it from green to amber, and checks still running keep it off green until they finish. It never lights a bar on its own. A repository with no checks answers null, and a token refused the field degrades to the same value, so both render as Checks unknown rather than as a failure — and a refusal on that one field costs the field, not the poll. That distinction matters: under the old rule any error failed the whole poll, which is what left this axis frozen from 1.7.0 to 1.10.0.

The rollup is read off the pull request, never through commits(last:1). Resolving a PullRequestCommit needs Contents: read — read access to every line of source in every installed repo, to power a tray icon — and this App deliberately does not request it. That detour is the bug above, and a guard test fails if the document ever walks through commits again.

Why a GitHub App

All three queries need to see PRs in private repos. The only classic OAuth scope that can is repo, which also grants write to everything you can reach. Rather than hand out a key that broad, PR status uses one shared fine-grained GitHub App with read-only Pull requests, Metadata, Checks and Commit statuses permissions. The last two read the check rollup the PR page shows; see the note above for the stretch where they were held but unused. Contents is deliberately not requested, which is why no query may traverse a commit object. Device Flow needs no client secret, so its Client ID is public and baked in rather than configured.

Access to a private org is granted by installing the App there, not by a wider scope — an org owner approves once and every member benefits.

Signing in and installing are two steps, and GitHub offers no API that takes the second for you. So whenever the App turns out to be installed nowhere, at startup or after a sign-in, GitHoot opens onboarding in your browser. It shows the sign-in as done, explains the install, and links to github.com/apps/githoot-tray-icon/installations/new. While it waits, the page reloads every ten seconds and asks GitHub again each time, so an install made in another tab shows by itself and the bars come up without a restart. It replaced a dialog that did the explaining. With no local page server to show it, the install page opens directly. The App’s slug is still the old name: it was registered before the rename.

Onboarding also opens at every start while no portal is signed in, and on Windows it shows whether the owl is kept on the taskbar, with a button to the taskbar settings. For now it walks through the GitHub portal only: GitLab is in beta and not yet fully supported.

If you have authorized but see nothing, check whether it is installed anywhere: github.com/settings/installations. A confident “nothing to show” from a zero-installation App would be indistinguishable from genuinely having nothing, so the app checks at startup and after each sign-in, and turns the bars off with an explanation instead of guessing. An install made later shows up on the next start or sign-in, or straight away while onboarding is open.

The credential renews itself — proactively before expiry, and reactively if GitHub rejects it. Only when a renewal needs a browser does the exclamation come back.