Skip to content

Add per-reviewer response time - #32

Merged
hy0ug0 merged 3 commits into
mainfrom
hugocontrerasperso/ori-100-add-per-reviewer-response-time
Oct 9, 2026
Merged

hy0ug0 merged 3 commits into
mainfrom
hugocontrerasperso/ori-100-add-per-reviewer-response-time

Conversation

@hy0ug0

@hy0ug0 hy0ug0 commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Each reviewer row gets the time from a review request to that reviewer's response, as p50 and p90. A response is a submitted review or a conversation comment. The rules live in server/analysis/reviewer-response.ts, a pure module:

if pr.isDraft: no samples                                  // same as first response
prStart  = responseStart(pr)                               // max(createdAt, first readyForReviewAt)
closedAt = pr.state == OPEN ? null : pr.closedAt
isResponder(a) = isParticipant(a, pr, rules)               // shared participant rules (team filter, bots)

target(event):
  user or bot -> individual, key lower(login); skipped unless isParticipant (author, team filter,
                 and bots unless "Include bots" is ticked: then a requested bot gets samples)
  team        -> team, key lower(slug); membership is not checked
  null        -> skipped (a team the token can't see)

responses = reviews (not PENDING, submittedAt set; DISMISSED counts) + conversation comments
            by a responder, with prStart <= time <= closedAt
items = events + responses by time; at equal times, events first

for item in items:
  Requested(individual U, t): if U not open: openInd[U] = t           // pending: keep the first start
  Requested(team T, t):       if T not open: openTeam[T] = {t, seen: {}}
  Removed(U or T):            close it, no sample
  Response(P, r):
    if P has an open individual request: sample (P, r - max(start, prStart)), close it
    else: the earliest open team request P hasn't seen answers it: sample, close it
          (P is any responder; GitHub team membership is not checked)
    every open team request has now seen P     // only P's first response after a team request can answer it
end: requests still open give no sample

horizon = min(last fetched review,  if the review continuation failed;
              last fetched comment, if more comments exist;
              last fetched event,   if more events exist)
samples with r > horizon are dropped (an unseen review, comment or removal could change them)

Choices the issue left open:

  • Re-requests. A re-request while pending keeps the first start. A re-request after the reviewer responded starts a new sample, so each review round counts.
  • Removed requests. They close the clock with no sample, for a person or a team.
  • Requester. The query selects only the requested reviewer, never the event's actor, so the two can't be mixed up. No metric needs the requester yet.
  • Rows. Rows still come from counted reviews in range. Someone who answered requests only by comment gets no row; the README says so.
  • Population. Samples come from every listed PR (created in range), like first response, whatever the request or response date.
  • Team attribution. A team request goes to the first qualifying responder after it, whether or not they belong to the team. Checking membership needs Team.members, the read:org scope and extra query cost, and the token can't see private teams.
  • Bots. The issue said requests to bots are skipped. They are by default, but with "Include bots" ticked a requested bot is a reviewer like any other and its responses give samples, as in every other metric.
  • First response, too. First response had the same blind spot with missing review pages: a PR whose only fetched response came after the last fetched review counted as responded. It is now undetermined, like the existing case for comments.

Fetching and the model:

 PullRequestFields
   timelineItems(READY_FOR_REVIEW_EVENT, first: 1)
+  reviewRequestEvents: timelineItems([REVIEW_REQUESTED_EVENT, REVIEW_REQUEST_REMOVED_EVENT], first: 50)
+    pageInfo { hasNextPage }          // totalCount ignores itemTypes
+    nodes { __typename createdAt requestedReviewer { ...RequestedReviewerFields } }

 PullRequest
-  reviews: { nodes }
+  reviews: { pageInfo: { hasNextPage }, nodes }   // true only when the continuation failed (partial, never cached)
+  reviewRequests: { pageInfo: { hasNextPage }, nodes: [{ kind: requested|removed, createdAt, reviewer: user|bot|team|null }] }

 fetchPullRequestsInBatches
-  PR_BATCH_SIZE = 50
+  PR_BATCH_SIZE = 25
+  more than 50 events -> "partial" (shown, never cached)

 ReviewerStats
+  responseP50Ms, responseP90Ms (null without a sample), responseSamples

ReviewerTable gets sortable "Response p50" and "p90" columns. Nulls sort last in both directions and show "–". The sample count sits in small muted text before p50 (n=7 19h 8m). With fewer than 5 samples, p90 is dimmed (gray-500 against the gray-900 of the other response values), and the header tooltip and the cell title say why. The CSV export adds p50 and p90 in minutes and the sample count.

Every sortable header now holds a native <button> with a visible focus ring, and the <th> carries aria-sort (ascending, descending or none). A pseudo-element keeps the whole cell clickable, as before. The info icons are buttons too: their tooltip opens on hover and on keyboard focus and closes on Escape, and screen readers read it as the button's description.

Evidence

Tests. bun run check and bun run test pass (222 tests). reviewer-response.test.ts encodes six real timelines:

Case What it checks
Team plus three people, one of them re-requested twice each re-request is a round; the team request goes to the member who answered it
Team asked during draft, people after it the clock starts at ready for review, not at the draft-time request
Own request before the team's the seen set: a later review by that member doesn't answer the team request
Team removed seconds after the request no sample, even with a later member review
nodejs/node#66298: responses before the request; 16 comments, 10 fetched Renegade334 2m49s, panva 2m34s, ronag 40h30m; an approval past the comment page is dropped
nodejs/node#65986: requested, removed, requested again in 14 s ronag 27m47s from the last request

The first four cases are anonymized timelines from a private repository.

To check that the tests catch broken rules, I broke one rule at a time. Each of these failed tests: no seen set (2 fail), the clock starting at a request made during draft (2 fail), a re-request resetting the start (1 fails), no horizon (2 fail), no individual precedence (13 fail). Dropping the events-before-responses tie-break failed nothing, so I removed it. Events go into the list first and the sort is stable, which gives the same order.

Regression tests for a failed review continuation, from the review: a request at 10:00, a fetched review at 10:30, an unfetched review at 11:00 and a fetched comment at 13:00. Reviewer response time now drops the 3 h sample instead of reporting it, and first response is undetermined. Turning the review horizon off fails both tests.

Live run. Server on :3100 with a fresh cache, client on :5300. A run on a private repository (216 PRs in 9 batches of 25, complete, 12 GitHub requests, 6.1 s cold) was also cross-checked against its REST timelines.

  • nodejs/node, 2026-09-28 to 2026-10-04: 120 PRs in 5 batches, 4.5 s cold, complete. Node rarely requests reviews explicitly: 24 samples over 15 of 40 rows.

Hand checks against gh api repos/{repo}/issues/{n}/timeline (REST, separate from the GraphQL path):

  • nodejs/node: mcollina on #66369, 538,399 s (6d 5h33m). jasnell on #66430, 147,380 s. panva on #66432, 2,066 s. All three match the REST timelines to the second.

UI. Checked in Chrome at 1280 px, light and dark, and at 390 px:

  • The headers stay on one line, and the table scrolls horizontally at 390 px (356 px visible, 1,216 px wide).
  • Sorting p50 descending and then ascending keeps the "–" row last both times.
  • The CSV export has the new columns (p50 and p90 in minutes, then the sample count), with empty cells for a reviewer without samples.
  • Keyboard, on nodejs/node: Enter on the focused "Response p50" button sorts descending, then ascending, and aria-sort follows. Tab moves to "About Response p50", which shows the tooltip and is described by its text. Clicking the header padding still sorts. The accessibility tree names each column header by its label alone ("Total", not "Total About Total").

Merge Danger

Door: two-way

Reverting restores the old fragment and PULL_REQUEST_CACHE_VERSION = 5. Version 6 entries are then ignored, and the version 5 entries left on disk can be read again.

Blast Radius: fetching

  • Cache bump to 6. The first analysis after deploy refetches every PR. On a 1,000-PR range, that is about 40 GraphQL calls instead of 20, at the same point cost (1 point per 25 PRs).
  • Batch size 50 to 25. With the events connection, 50 of the busiest nodejs/node and microsoft/vscode PRs took 10 to 11 s and GitHub answered 502/504. 25 take 3 to 7.5 s. Cold loads make twice the calls, with 5 calls in flight at a time.
  • Partial PRs. A PR with more than 50 review request events is never cached. The most seen among about 1,250 PRs sampled was 32.

Closes ORI-100

🤖 Generated with Claude Code

@hy0ug0
hy0ug0 force-pushed the hugocontrerasperso/ori-100-add-per-reviewer-response-time branch from 642661a to 52e2fb0 Compare October 9, 2026 21:08
For each reviewer, the time from a review request (to them or one of
their teams) to their next review or conversation comment. A team
request goes to the first member who responds; draft time is excluded;
a re-request after a response starts a new round; removed requests and
requests still pending give no sample. The rules live in
server/analysis/reviewer-response.ts, with tests built from real
timelines.

Each PR now carries its review requests and removals, normalized to the
requested reviewer (user, bot or team; null when GitHub hides it). The
requester is not fetched. A PR with more than 50 events is served as
partial and never cached. The extra connection made 50-PR batches time
out on the busiest nodejs/node and microsoft/vscode PRs, so batches drop
to 25 at the same cost per PR. The PR cache moves to version 6.

The reviewer table gets sortable "Response p50" and "p90" columns, with
the sample count next to p50 and p90 dimmed under 5 samples. The CSV
export carries both, in minutes, and the sample count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hy0ug0
hy0ug0 force-pushed the hugocontrerasperso/ori-100-add-per-reviewer-response-time branch from 52e2fb0 to 95d6663 Compare October 9, 2026 21:11
hy0ug0 and others added 2 commits October 9, 2026 23:19
When the review continuation fails, the PR keeps only its inline reviews,
so an unfetched review could be an earlier response than a fetched
comment. PullRequest.reviews now carries pageInfo.hasNextPage, true only
for such a partial PR, which is never cached, so the cache version stays
at 6.

Reviewer response time drops samples after the last fetched review, as
it does after the last fetched comment or event. First response had the
same blind spot: a PR whose only fetched response comes after the last
fetched review is now undetermined.

The code and README also say that team requests go to the first
qualifying responder, without a membership check, and that requested
bots give samples when bots are included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each column header now holds a native button for sorting, with a visible
focus ring, and the header carries aria-sort. The info icons are buttons
too: their tooltip opens on hover and on keyboard focus, closes on
Escape, and screen readers read it as the button's description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hy0ug0
hy0ug0 merged commit 4cad75e into main Oct 9, 2026
1 check passed
@hy0ug0
hy0ug0 deleted the hugocontrerasperso/ori-100-add-per-reviewer-response-time branch October 9, 2026 21:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant