ReviewTide refuses to rank people. That is the first thing to know about it, before any feature. It measures how a change moves through review, not how "productive" a person is, because the moment a tool puts a number next to a name the number becomes the target and the work bends to it. This README leads with the refusal because it is a design position, not an omission: the rest of the tool follows from it.
ReviewTide reads two offline exports, a git log --numstat file and a pull
request export in JSON Lines, and reports queue time before first review, first
response latency, review depth, and per-directory knowledge concentration as a
bus-factor signal. It runs on the Python standard library only: no third-party
packages, no network access anywhere. The three figures above are the real
median and p90 first-response latency and the reviewed-PR sample size from the
fixtures in samples/, captured by running the CLI in this session. The sample
size is on the strip on purpose, so a small sample can never hide behind a
confident-looking percentile.
There is no lines-per-day figure here. No commits-per-person leaderboard. No ranking of individuals by output of any kind. That absence is the product.
Ranking people by line count rewards churn and punishes the careful work that keeps a codebase alive: deleting dead code, reviewing thoroughly, pairing, mentoring, and writing the small change that took a day of thought. A dashboard that scores those at zero will, over time, produce a team that stops doing them. ReviewTide is built to measure the health of the review process itself, so the one number it withholds is the one that would corrupt the behaviour it wants to observe.
The single place ReviewTide does count activity per author is the bus-factor signal, and even there the unit is inverted. The question is not "who did the most" but "how few people would we have to lose before this directory has nobody who has recently touched it". That is a risk measure for the team, not a scoreboard. Leading with the refusal matters because every later section (percentiles over means, touches over line counts, offline exports over a live API) is a consequence of the same stance.
ReviewTide targets Python 3.11 or newer. Install it as an editable package,
which registers a ReviewTide console command:
pip install -e .
You can also run it without installing by pointing Python at src:
PYTHONPATH=src python -m ReviewTide version
ReviewTide flow --pr <pr_export.jsonl>
ReviewTide ownership --gitlog <gitlog.numstat>
ReviewTide report --pr <pr_export.jsonl> --gitlog <gitlog.numstat>
ReviewTide version
| Command | Reads | Reports |
|---|---|---|
flow |
PR export | queue time, first response latency, depth |
ownership |
git numstat export | per-directory concentration and bus factor |
report |
both exports | flow then ownership in one pass |
version |
nothing | the package version |
Four measures, each defined precisely, each with what it cannot tell you. The
definitions live in src/ReviewTide/flow.py and src/ReviewTide/ownership.py.
The number of hours between a pull request opening (opened_at) and the
timestamp of its first review event, summarised across all reviewed PRs. It
answers "how long does a change wait before anyone engages with it".
What it cannot tell you: in this export, queue time and first response latency are the same measurement, because the export carries no separate "ready for review" moment. If your process distinguishes "opened" from "marked ready", ReviewTide treats the open time as the start of the wait.
The same underlying measurement as queue time, reported under its own label so the two concepts stay legible: hours from open to first review. It is kept separate because teams reason about "queue time" (a property of the backlog) and "first response" (a property of reviewers) differently, even when the export cannot yet separate them.
What it cannot tell you: it says nothing about the quality of that first response. A one-word "LGTM" and a detailed critique count the same here.
Two numbers per reviewed PR: the number of review rounds (one review event is one round) and the total comments summed across those rounds. Reported as percentiles over the reviewed PRs.
What it cannot tell you: comment count is not comment weight. A PR with ten nitpick comments looks deeper than one with a single comment that caught a real defect. Depth is a proxy for engagement, not rigour.
For each top-level directory, the number of distinct authors who touched it, the total touches, the share held by the most active author, and the bus factor: the smallest set of authors whose combined share of touches exceeds 50 percent. A "touch" is one commit that changed at least one file in the directory, counted once regardless of how many files or lines it changed.
What it cannot tell you: it does not weight by recency or by how critical a directory is. A directory of many trivial commits and one of a few large, careful commits can produce a similar bus factor. It reads only the git export; work done outside version control is invisible.
Every statistic prints the sample size it was computed from, written as n=
next to the label, so a small sample cannot masquerade as a trend. The strip at
the top of this README prints n=18 for exactly that reason.
Latency is summarised with percentiles, never a mean. A mean hides the shape of
a skewed distribution and lets one very slow review, or one instant merge, drag
the headline away from the typical experience. Percentiles preserve both: the
p50 shows the typical case, the p90 shows the tail. The method is linear
interpolation between closest ranks, in percentile() in flow.py, so results
are reproducible without a third-party library.
Below a sample of five, ReviewTide withholds the percentiles entirely. Instead
of reporting a p90 built from two data points, which would carry the authority
of a statistic and the reliability of a coin flip, it prints the raw values and
an explicit message that the sample is too small. The threshold is
MIN_SAMPLE_FOR_PERCENTILE = 5 in flow.py, and the small-sample branch reads:
sample too small for percentiles (need n>=5); raw values in hours:
This is the honest failure mode: say "I do not have enough data" rather than manufacture a confident answer.
Bus factor uses a simple majority rule: the smallest number of authors whose
combined touches exceed half of a directory's total. A bus factor of 1 means a
single author accounts for the majority of activity, so if that person becomes
unavailable, nobody else has recently worked in that directory. Directories
sort riskiest first (rising bus factor, then falling top share, then name), an
ordering defined at the end of compute_ownership() in ownership.py.
In the sample fixtures, two directories are owned by a single author. infra
is touched only by Dana Kim across every commit that changes it, and docs is
touched only by Carla Nunez. Both report bus_factor=1 with authors=1 and a
100 percent top share. infra is the directory the sample was built to
surface: the clearest single-owner risk in the export. The remaining
directories (cli, tests, src) also report bus_factor=1 because one
author holds a majority even where two authors are present, which is exactly
the concentration the majority rule is designed to catch.
The block below was captured in this session by running the CLI against the
fixtures in samples/. It is pasted verbatim.
Command:
ReviewTide report --pr samples/pr_export.jsonl --gitlog samples/gitlog.numstat
Output:
== flow ==
pull requests in export: 20
never reviewed: 2
queue_time_before_first_review_hours (n=18)
p50 3.8h
p75 17.5h
p90 49.2h
min 1.0h
max 67.0h
review_depth (n=18)
p50 rounds 1.0 comments 4.0
p75 rounds 1.0 comments 5.0
p90 rounds 2.0 comments 7.3
== ownership ==
commits in export: 20
directories: 5
directory concentration (riskiest first)
docs: bus_factor=1 authors=1 touches=4 top=Carla Nunez (100%)
infra: bus_factor=1 authors=1 touches=6 top=Dana Kim (100%)
cli: bus_factor=1 authors=2 touches=3 top=Ben Osei (67%)
tests: bus_factor=1 authors=2 touches=5 top=Ben Osei (60%)
src: bus_factor=1 authors=2 touches=7 top=Alice Rivera (57%)
Reading this run: 20 PRs in the export, 2 of which (ids 107 and 116 in the
sample) were never reviewed, so the flow sample is n=18 rather than 20.
Median first response is under four hours, but the p90 of 49.2 hours and the
max of 67.0 hours show a long tail. The ownership block flags docs and
infra as single-owner directories, the finding that should trigger a
conversation about spreading knowledge before those owners take leave.
The histogram below shows the shape behind the flow percentiles, drawn from the same run. Its embedded numbers match the output above: n=18, p50=3.8h, p90=49.2h, max=67.0h.
The first bin (0 to 6 hours) holds 10 of the 18 reviewed PRs, which is why the median sits at 3.8 hours. The rightmost bin (48 to 72 hours) is drawn in amber because it holds the p90 tail: the two reviews that took over two days are the ones worth asking about, so the colour points the eye there rather than at the tall first bar.
ReviewTide reads two files. Neither is fetched: you export them yourself and pass the paths in.
Produce it with:
git log --numstat --date=iso-strict --pretty=format:'commit%x09%H%x09%an%x09%ad'
Each commit is a header line followed by one numstat row per changed file.
Parsing lives in src/ReviewTide/gitlog.py.
| Line type | Fields (tab separated) | Notes |
|---|---|---|
| commit header | commit, hash, author name, ISO date |
literal token commit in field one |
| numstat row | added, deleted, path | added/deleted are - for binary files |
Binary files report - for both counts, which ReviewTide keeps as None so a
binary change is distinguishable from a zero-line text change. A numstat row
before any commit header is a parse error.
One JSON object per line, one object per pull request. Parsing lives in
src/ReviewTide/prexport.py.
| Field | Type | Meaning |
|---|---|---|
id |
integer or string | unique per pull request |
author |
string | login of the person who opened it |
directories |
list of strings | top-level directories the change touched |
opened_at |
ISO 8601 string | when the PR was opened |
reviews |
list of objects | review events; may be empty (never reviewed) |
Each entry in reviews carries three fields:
| Field | Type | Meaning |
|---|---|---|
reviewer |
string | login of the reviewer |
submitted_at |
ISO 8601 string | when the review was submitted |
comments |
integer | comment count in that review |
An empty reviews list is a real signal (the PR was never reviewed) and is
preserved rather than dropped. Reviews are sorted chronologically on parse so
first-response logic does not depend on export ordering. A missing required
field or malformed JSON is a parse error that stops the run with exit code 2.
See samples/ for a worked pair of fixtures and samples/README.md for how
they were constructed.
Output is line-oriented and deterministic: fixed field order, no wall-clock time, no randomness, so two runs of the same input diff cleanly in git.
| Line | Meaning |
|---|---|
pull requests in export: N |
count of PR objects parsed |
never reviewed: N |
PRs with an empty reviews list |
<label> (n=N) |
a statistic and the sample size it rests on |
pXX V.Vh |
latency percentile in hours |
min / max |
extremes of the latency sample |
pXX rounds R.R comments C.C |
review-depth percentile |
commits in export: N |
count of commits parsed from the git log |
directories: N |
distinct top-level directories seen |
<dir>: bus_factor=B authors=A touches=T top=... (P%) |
one directory concentration line |
The report command wraps the two blocks with == flow == and
== ownership == headers.
Defined in src/ReviewTide/cli.py.
| Code | Name | Meaning |
|---|---|---|
| 0 | EXIT_CLEAN |
ran successfully, no findings |
| 1 | EXIT_FINDINGS |
a finding is present: a bus-factor-1 directory or an unreviewed PR |
| 2 | EXIT_USAGE |
usage error: missing file or malformed export |
A finding is not an error: exit code 1 means the tool found something worth attention, which makes it usable in CI (fail on findings, pass otherwise).
ReviewTide measures flow, not quality, and cannot see anything outside the exports you give it.
- Queue time and first response latency are the same measurement here: time from open to first review. If your process distinguishes "ready for review" from "opened", ReviewTide cannot see that, because the export does not carry it.
- A "touch" for ownership is one commit that changed a directory, counted once regardless of file or line count. A directory touched by many trivial commits and one touched by a few large commits can look similar.
- Bus factor uses a simple majority rule. It does not weight by recency or by how critical a directory is to the system.
- ReviewTide reads only what is in the exports. It does not fetch anything and cannot reconstruct reviews, comments, or conversations that were never recorded in the export. Discussion that happened in chat, in a call, or in a tool that did not make it into the JSON Lines file is invisible.
- The tool does not merge a PR export with a git log by identity; the two are reported side by side. Matching a specific PR to its commits is out of scope.
Each decision records the alternative that was rejected and why.
Rejected: reporting the mean first response latency. A single 67-hour outlier can drag a mean well above the typical experience, making a mostly-healthy queue look slow. Percentiles preserve the shape a mean flattens into one misleading figure.
Rejected: a lines-per-author or commits-per-author summary. It is easy to compute from the same git export and is the number teams ask for first. It was left out because it rewards churn, punishes deletion and review, and turns a measurement tool into a surveillance one. The bus-factor signal is the deliberate substitute: it uses per-author data to measure team risk, not individual output.
Rejected: calling a hosting provider's API directly. An API integration would remove the export step, but it would bind the tool to one provider's schema and auth model, require network access and credentials, and make runs non-reproducible as the upstream data shifts. Reading a file you exported keeps ReviewTide provider-agnostic, offline, dependency-free, and deterministic: the same file always produces the same report.
ReviewTide/
README.md this file
LICENSE MIT license text
CHANGELOG.md release notes, starting at 0.1.0
pyproject.toml package metadata and the ReviewTide console script
.gitignore ignores caches and build artifacts
src/ReviewTide/
__init__.py package marker and version string
__main__.py enables python -m ReviewTide
cli.py argument parsing, subcommands, exit codes
gitlog.py parse git log --numstat exports
prexport.py parse JSON Lines pull request exports
flow.py latency, queue time, review depth, percentiles
ownership.py per-directory concentration and bus factor
report.py deterministic line-oriented rendering
samples/
README.md how the fixtures were built and what they exercise
gitlog.numstat 20 commits across 5 directories
pr_export.jsonl 20 PRs, 2 never reviewed
docs/assets/
logo.svg the wordmark, review in slate and tide in teal
review-latency.svg first response latency histogram for the sample
tests/
test_cli.py CLI exit codes and output on the fixtures
test_flow.py percentile math, latency, depth, small-sample rule
test_gitlog.py numstat parsing and error cases
test_ownership.py top-level dir, bus factor, at-risk detection
test_prexport.py JSONL parsing, sorting, error cases
| Term | Meaning in ReviewTide |
|---|---|
| queue time | hours a PR waits from open to first review |
| first response latency | same measurement as queue time, reported under its own label |
| review round | one review event on a PR |
| review depth | rounds and total comments per reviewed PR |
| touch | one commit that changed at least one file in a directory, counted once |
| top share | fraction of a directory's touches held by its most active author |
| bus factor | smallest set of authors whose combined touches exceed 50 percent |
| at-risk directory | a directory with bus factor 1: a single dominant author |
| never reviewed | a PR whose reviews list is empty |
| n | the sample size a statistic was computed from, printed with every stat |
The test suite runs on the standard library test runner with no extra dependencies:
PYTHONPATH=src python -m unittest discover -s tests -v
In this session the full suite reported:
Ran 34 tests in 0.005s
OK
The 34 tests break down as 6 in test_cli.py, 9 in test_flow.py, 6 in
test_gitlog.py, 7 in test_ownership.py, and 6 in test_prexport.py. They
cover the percentile math and small-sample withholding rule, latency in hours
with unreviewed PRs excluded, review-depth sums, numstat parsing (including
binary - counts and malformed headers), JSONL parsing (including review
sorting, preserved empty reviews, and parse errors), and the CLI exit codes,
sample-size output, single-owner detection, and deterministic repeat runs.
No dates are promised. In rough order of intent:
- An optional "ready for review" timestamp so queue time and first response latency can be separated when the export carries it.
- Recency weighting for the bus-factor signal so an owner who left six months ago does not count the same as an active one.
- A machine-readable output mode (JSON) alongside the current text, and a way to diff two reports so a team can see whether concentration and latency improve between exports.
MIT. See LICENSE.