Measuring review latency from git history Jump to heading

Every team has an opinion about how long reviews take, and the opinions disagree by a factor of five. That is not because people are unobservant but because the experience is dominated by the worst case, and the worst case is rare. A handful of commands turns the argument into a distribution, and a distribution tells you which stage to fix β€” which is usually not the one anybody was arguing about. This recipe produces the numbers and the counterweight that stops them being gamed, within code review workflow engineering.

When to use this approach Jump to heading

  • Review speed is being discussed and nobody has figures.
  • You are about to change the process and want a baseline.
  • You have made changes and need to know whether they worked.
  • Someone has proposed a target and you would like to know if it is achievable.
  • If your repository has fewer than fifty merged changes, the sample is too small for medians to mean much.

Step 1 β€” Pull the raw events Jump to heading

The forge holds the timestamps; Git alone cannot tell you when a review happened.

gh pr list --state merged --limit 200 \
  --json number,createdAt,mergedAt,reviews,additions,deletions,labels \
  > /tmp/prs.json

jq 'length' /tmp/prs.json
# Verification: the sample covers a representative period
jq -r '[.[].createdAt] | sort | "\(.[0][0:10]) to \(.[-1][0:10])"' /tmp/prs.json

Step 2 β€” Split elapsed time into waiting and working Jump to heading

The single number β€œtime to merge” hides the distinction that matters.

jq -r '
  map(select(.reviews | length > 0)) |
  map({
    pr: .number,
    wait_h: (((.reviews[0].submittedAt | fromdate) - (.createdAt | fromdate)) / 3600),
    work_h: (((.mergedAt | fromdate) - (.reviews[0].submittedAt | fromdate)) / 3600)
  }) |
  {
    n: length,
    median_wait: (map(.wait_h) | sort | .[length/2|floor] | floor),
    median_work: (map(.work_h) | sort | .[length/2|floor] | floor),
    p90_wait: (map(.wait_h) | sort | .[(length*0.9)|floor] | floor)
  }' /tmp/prs.json
# Verification: the two medians should sum to roughly the median total
jq -r 'map(select(.mergedAt) | ((.mergedAt|fromdate) - (.createdAt|fromdate))/3600)
       | sort | .[length/2|floor] | floor' /tmp/prs.json
Median and ninetieth percentile, side by sideThe median wait for a first review is a few hours, which matches nobody's complaint. The ninetieth percentile is nearly two days, which is what everyone remembers. Optimising for the tail is a different exercise from optimising for the median.hours waiting for a first reviewmedian5 h75th percentile14 h90th percentile43 harguments about review speed are almost always arguments about the right-hand bar

Step 3 β€” Correlate with size, because that is the usual cause Jump to heading

jq -r '
  map(select(.reviews | length > 0)) |
  map({size: (.additions + .deletions),
       wait: (((.reviews[0].submittedAt | fromdate) - (.createdAt | fromdate)) / 3600)}) |
  group_by(if .size < 100 then "S" elif .size < 400 then "M" else "L" end) |
  map({bucket: (if .[0].size < 100 then "S" elif .[0].size < 400 then "M" else "L" end),
       n: length,
       median_wait_h: (map(.wait) | sort | .[length/2|floor] | floor)})' /tmp/prs.json
# Verification: the relationship should be monotonic; if not, something else dominates

If waiting time rises steeply with size, the fix is structural and belongs in splitting a branch into reviewable pull requests. If it does not, the cause is routing or capacity, and assigning reviewers automatically is the relevant lever.

What the correlation tells you to fixIf waiting time grows with change size, reviewers are deferring large changes and the answer is to make changes smaller. If it does not, the delay is in noticing the change at all, which is a routing problem rather than a reading problem.Does waiting time rise sharply with change size?yes, steeplyStructuralsplit and stackflat across sizesRoutingassign owners, set expectationshigh everywhereCapacitytoo few reviewers for the loadthree different problems that produce the same complaint

Step 4 β€” Track the quality counterweight Jump to heading

Latency alone is trivially improvable by approving faster, so it must never be published alone.

# Approvals that carried no comment at all
jq -r '
  map(select(.reviews | length > 0)) |
  {total: length,
   silent: (map(select([.reviews[] | select(.body != "")] | length == 0)) | length)} |
  "\(.silent) of \(.total) approved without a single comment"' /tmp/prs.json
# Comment density by size bucket, which is the honest quality proxy
jq -r 'map({size: (.additions + .deletions), comments: (.reviews | length)})
       | group_by(if .size < 100 then "S" elif .size < 400 then "M" else "L" end)
       | map({bucket: (if .[0].size < 100 then "S" elif .[0].size < 400 then "M" else "L" end),
              avg_reviews: ((map(.comments) | add) / length)})' /tmp/prs.json

SAFETY WARNING β€” publishing review latency as a target without a quality measure beside it reliably produces faster approvals rather than faster reviews, and the change is invisible until a defect reaches production. Publish the pair, and treat a fall in comment density alongside a fall in latency as a regression rather than a success.

Step 5 β€” Publish one figure, on a schedule Jump to heading

A number nobody sees changes nothing. One number, weekly, where the team already looks.

#!/usr/bin/env sh
# scripts/review-latency β€” one line, suitable for a weekly post.
set -eu
gh pr list --state merged --limit 100 --json createdAt,reviews,additions,deletions |
jq -r '
  map(select(.reviews | length > 0)) |
  (map(((.reviews[0].submittedAt | fromdate) - (.createdAt | fromdate)) / 3600) | sort) as $w |
  "median wait \($w[($w|length/2|floor)] | floor)h Β· " +
  "p90 \($w[($w|length*0.9|floor)] | floor)h Β· " +
  "silent approvals \(map(select([.reviews[] | select(.body != "")] | length == 0)) | length)%"'
# Verification: the script runs in under a second and prints one line
sh scripts/review-latency
What the measurement is forMeasure first to get a baseline, change one structural thing, wait long enough for the sample to refresh, then measure again. Changing several things at once produces a number that moved for reasons nobody can attribute.Baselinemedian and p90 recordedweek 0One changesplit large changesweek 1Sample refreshedenough merges to compareweek 3Re-measureattributable resultweek 4one change at a time is what makes the second measurement mean anything

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not use the average? Jump to heading

Because review latency has a long tail and the average sits somewhere nobody experiences. The median describes the typical case and the ninetieth percentile describes the case people complain about; together they say more than any single statistic.

Should time outside working hours be excluded? Jump to heading

For comparing teams in different time zones, yes, and the arithmetic gets fiddly. For tracking your own team over time it rarely changes the conclusion, because the working-hours distortion is roughly constant. Start simple and refine only if the number is being disputed.

Does this work without a forge API? Jump to heading

Partially. Merge timestamps are derivable from Git, so time-to-merge is available from history alone. First-review time is not recorded anywhere in Git, so that half needs the forge β€” and it is the half that matters most.