Identity from issue events, not the timeline
A review request is an event, not a state — and on a busy pull request, reading it from the timeline carried sixteen times the payload to find it.
The obvious way to find out whether someone wants your review is to ask who a pull request’s reviewers currently are. That is requested_reviewers, and it cannot do the job. It is state: GitHub clears you out of it the moment you submit a review. Poll it and you learn who is wanted now, never what has been asked before — so a request you already answered and one nobody has answered look the same, and anything that happened between two polls is gone.
That gap matters, because a second request on a revision nobody has touched is a distinct act rather than a repeat of the first: somebody wants another look at code you have already reviewed. Engwire honours it, so it needs the durable record rather than the current state — the review_requested issue events, each with its own id.
Two endpoints carry those events. issues/<n>/timeline interleaves them with every commit, comment, review and cross-reference. issues/<n>/events carries issue events only. In the responses measured, that meant every review_requested entry the timeline had, and none of the traffic around them.
What the timeline costs to read
For oven-sh/bun#30412, measured on 3 September 2026 with --paginate and per_page=100 on both:
| entries | pages | body | |
|---|---|---|---|
timeline |
1,663 | 17 | 6,049 KB |
events |
305 | 4 | 375 KB |
That pull request goes on collecting comments, so both figures move — and so does the ratio, which depends on what kind of activity accumulates. One measurement is enough to justify the cheaper endpoint. It is not enough to name a general factor. Engwire reads this once per candidate on every poll, which makes it a per-minute cost rather than a one-off.
One gh detail worth keeping: from 2.31.0, --paginate without --jq merges the pages into a single array, so the result is one parse. Before that, the pages arrive as concatenated JSON documents that a single JSON.parse refuses — and since a history that fits one page parses on either version, an old gh looks healthy right up until somebody’s busy pull request.
What the two endpoints agree on
Across eight pull requests in two repositories — from 9 to 1,663 timeline entries — the two agreed on every field Engwire reads, for all nine review_requested entries between them, plus node_id and url.
Those last two carry the argument. Equal payload fields could be a coincidence of two records; an equal node_id says both responses are describing the same underlying object. That is an inference from nine entries rather than a promise from GitHub, and it is worth exactly that much: it justifies the migration, not a claim that the endpoints must agree in general.
commit_id was null on all nine, and that is the other thing Engwire leans on. The event does not say which revision the review was requested for. So the revision comes from the pull request instead: its head as it stood when discovery saw the request. The event supplies the id, the timestamp and who was asked. A commit is the one thing it does not supply.
What the id space is doing
The matching id is what made the switch free: UNIQUE(event_id) still matches against a database an earlier version wrote, so nothing is reviewed twice for having changed endpoints.
That constraint is the strongest dedup available — a review request cannot be acted on twice even if the runner dies between deciding and executing — and it rests on treating the numeric event id as globally unique rather than unique per repository. That is an assumption, and it was checked rather than inherited: ids sampled from three unrelated repositories fell inside one narrow band, never collided, and ordered the same way as their timestamps. A sample cannot prove global allocation. It can say the assumption is not obviously wrong, which is the most a sample ever does.
Discovery filters to review_requested either way, so no comment id was ever going to reach that constraint. What the events endpoint adds is that none is ever in hand: the timeline’s reviews and comments carry ids from a different and shorter range, and the cheaper endpoint never fetches them at all.
One limit, since the sample invites the question: of the nine entries, five named a user and four named a team. Engwire acts only on the first kind. A team request carries requested_team instead of requested_reviewer, and answering it would mean resolving the reviewer’s team memberships first.
The shape generalises, if you are building anything that reacts to GitHub. Ask what the durable record is before you ask which endpoint is convenient: current state is cheap to read and cannot answer a question about the past. Then take the narrowest endpoint that carries that record — after checking the identifiers match, because that is what decides whether the switch is a migration or a second pass over everything you have already done.