reviewroute
CODEOWNERS files rot: patterns that match nothing, owners who left, files nobody owns, and a few people drowning in review requests. reviewroute lints your CODEOWNERS against the real repository, roster and git history, measures ownership coverage, and suggests a minimal, load-balanced set of reviewers for a change.
Features
- Precise parser for GitHub semantics (last match wins,
*vs**, leading/, directory patterns, escaped\#, no!negation) and a GitLab variant (--gitlab):[Section],[Section][2]approval counts,^[Optional]sections, section default owners. Owners:@user,@org/team, email. - Lint (
RR001-RR010): invalid syntax, patterns matching no files, rules fully shadowed by later rules, duplicate rules, misplaced catch-all, unknown or departed owners (roster file, or the GitHub API), owners with no recent activity in their areas, teams listing departed members. Rich, JSON or GitHub-annotation output and CI exit codes. - Coverage: % of files and lines owned, unowned hotspots ranked by churn, owner concentration, bus-factor-1 directories, team vs individual ratio.
who PATHexplains which rule matched. - Suggest reviewers for a diff: required owner groups from the rules, recency-weighted git expertise, author/vacation exclusion, load balancing from a CSV (or the GitHub API), greedy set cover for the smallest set, and an explanation per pick.
- Generate a CODEOWNERS draft from git history, collapsed to roster teams.
Install
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
Requires Python 3.12+, git on PATH.
Quickstart
.venv/bin/python examples/make_repo.py # builds examples/demo-repo (6 teams, 15 devs, 40 dirs)
export PATH="$PWD/.venv/bin:$PATH"
reviewroute --repo examples/demo-repo lint
reviewroute --repo examples/demo-repo coverage
reviewroute --repo examples/demo-repo who services/billing/main.py
reviewroute --repo examples/demo-repo suggest --base main --author carla --load examples/demo-repo/load.csv
reviewroute --repo examples/demo-repo generate
The demo repo has a deliberately flawed .github/CODEOWNERS, an owners.yaml roster (three departed developers), an unavailable.yaml vacation list and a load.csv of open review counts. Its checked-out branch feature/refund-flow is one commit (touching four files) ahead of main. Outputs below were captured with --now 2026-10-16 (and make_repo.py --now 2026-10-16) so they are reproducible.
Usage
Global options go before the subcommand:
| Option | Meaning |
|---|---|
--repo PATH |
repository root (default .) |
--codeowners FILE |
default: .github/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS, .gitlab/CODEOWNERS |
--roster FILE |
owners.yaml; default <repo>/owners.yaml if present |
--gitlab |
parse GitLab sections and approval counts |
--now YYYY-MM-DD |
reference date for decay, inactivity and vacations (default today) |
lint
reviewroute lint [--format rich|json|github] [--fail-on error|warning|never] [--inactive-days 180] [--github-validate]
Exit code 1 when a finding at or above --fail-on exists (default error), 2 on usage errors.
┏━━━━━━┳━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Line ┃ Code ┃ Severity ┃ Message ┃
┡━━━━━━╇━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 2 │ RR010 │ warning │ team `@acme/platform` still lists departed member(s): erin │
│ 3 │ RR006 │ warning │ pattern `/docs/` is overridden by the identical pattern at line 35 │
│ 5 │ RR007 │ error │ catch-all `*` placed after 3 specific rule(s) (lines 2, 3, 4); it overrides │
│ │ │ │ them. Move it to the top. │
│ 7 │ RR005 │ error │ owner `@erin` is inactive (departed) │
│ 8 │ RR008 │ warning │ owner `@alice` made no changes in the 7 file(s) it owns during the last 180 days │
│ 9 │ RR010 │ warning │ team `@acme/security` still lists departed member(s): omar │
│ 11 │ RR002 │ warning │ pattern `/services/payment-gateway/` matches no files in the repository │
│ 12 │ RR004 │ error │ owner `@acme/sre-oncall` is not in the roster │
│ 17 │ RR008 │ warning │ owner `@acme/platform` made no changes in the 4 file(s) it owns during the last │
│ │ │ │ 180 days │
│ 19 │ RR004 │ error │ owner `@pavel-contractor` is not in the roster │
│ 20 │ RR005 │ error │ owner `@kira` is inactive (departed) │
│ 20 │ RR010 │ warning │ team `@acme/mobile` still lists departed member(s): kira │
│ 27 │ RR003 │ warning │ rule `/infra/k8s/` never takes effect: every file it matches is claimed by later │
│ │ │ │ rule(s) at line 28 │
│ 29 │ RR008 │ warning │ owner `@acme/platform` made no changes in the 6 file(s) it owns during the last │
│ │ │ │ 180 days │
│ 30 │ RR005 │ error │ owner `@omar` is inactive (departed) │
│ 31 │ RR005 │ error │ owner `@erin` is inactive (departed) │
│ 34 │ RR002 │ warning │ pattern `*.proto` matches no files in the repository │
│ 36 │ RR001 │ error │ negation with `!` is not supported in CODEOWNERS │
│ 37 │ RR001 │ error │ invalid owner `platform-team` (use @user, @org/team or an email) │
└──────┴───────┴──────────┴──────────────────────────────────────────────────────────────────────────────────┘
9 error(s), 10 warning(s) in .github/CODEOWNERS
| Code | Severity | Meaning |
|---|---|---|
| RR001 | error | invalid syntax (negation, bad owner, bad pattern, bad section header) |
| RR002 | warning | pattern matches no files |
| RR003 | warning | rule never takes effect (fully shadowed by later rules) |
| RR004 | error | owner not in roster (or not found via GitHub API) |
| RR005 | error | owner is inactive (departed) in the roster |
| RR006 | warning | duplicate pattern |
| RR007 | error | catch-all placed after specific rules |
| RR008 | warning | owner made no changes in the files it owns during --inactive-days |
| RR009 | warning | syntax that parses but is unsupported ([a-z] ranges, embedded **) |
| RR010 | warning | team lists departed members |
--format github prints workflow annotations such as
::error file=.github/CODEOWNERS,line=5,title=RR007::catch-all ....
coverage
Files owned: 257/276 (93.1%)
Lines owned: 10169/11151 (91.2%)
Ownership kind: team-only 191, individual-only 21, mixed 45 (team ratio 90%)
Unowned hotspots (by churn)
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
┃ File ┃ Churn ┃ Lines ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
│ legacy/reports/schema.sql │ 154 │ 102 │
│ apps/docs-site/main.py │ 148 │ 112 │
│ apps/docs-site/util.py │ 136 │ 100 │
│ apps/docs-site/README.md │ 112 │ 64 │
│ data/dashboards/test_main.py │ 108 │ 68 │
└──────────────────────────────┴───────┴───────┘
Owner concentration
┏━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┓
┃ Owner ┃ Files ┃ % of repo ┃ Lines ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━╇━━━━━━━┩
│ @acme/platform │ 89 │ 32.2% │ 2299 │
│ @acme/web │ 43 │ 15.6% │ 1752 │
│ @acme/payments │ 29 │ 10.5% │ 1626 │
│ @acme/data │ 28 │ 10.1% │ 1150 │
│ @acme/security │ 22 │ 8.0% │ 1098 │
└────────────────┴───────┴───────────┴───────┘
Single-owner directories (bus factor 1)
┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━━━┓
┃ Directory ┃ Owner ┃ Files ┃ Churn ┃ Critical ┃
┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━━━┩
│ services/ledger/ │ @alice │ 7 │ 710 │ yes │
│ apps/mobile-android/ │ @acme/mobile │ 8 │ 656 │ yes │
│ services/auth/ │ @acme/security │ 8 │ 598 │ yes │
│ legacy/billing-v1/ │ @erin │ 8 │ 526 │ no │
│ libs/core/ │ @farid │ 6 │ 178 │ no │
└──────────────────────┴────────────────┴───────┴───────┴──────────┘
--format json emits the same data (plus percentages) for dashboards.
who
line 5 * -> @acme/platform overridden
line 7 /services/billing/ -> @acme/payments @erin (inactive) WINS
services/billing/main.py is owned by: @acme/payments @erin
line 5 * -> @acme/platform overridden
line 10 /services/gateway/ -> @acme/platform WINS
services/gateway/util.py is owned by: @acme/platform
suggest
reviewroute suggest (--base REF | --files PATH... | --files-from FILE) [--author LOGIN] [--unavailable FILE] [--load CSV | --github-repo OWNER/NAME] [--half-life 90] [--load-penalty 0.1] [--format rich|json]
4 changed file(s), 4 required owner group(s)
Required owner groups
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ Group (any one of) ┃ Files ┃ Eligible candidates ┃
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ @acme/web │ 1 │ gwen, hiro, ines │
│ @acme/platform │ 1 │ dmitri, farid │
│ @alice │ 1 │ alice │
│ @acme/payments │ 1 │ alice │
└────────────────────┴───────┴─────────────────────┘
Suggested reviewers
@alice: covers @alice; @acme/payments. expertise 0.25 (4 decayed lines, last touched 262d ago), 4 open
review(s), score -0.15
@gwen: covers @acme/web. expertise 1.00 (16 decayed lines, last touched 48d ago), 2 open review(s), score
0.80
@farid: covers @acme/platform. expertise 0.46 (7 decayed lines, last touched 188d ago), 1 open review(s),
score 0.36
Excluded @bram: unavailable until 2026-10-26 (vacation)
Excluded @carla: is the change author
Excluded @erin: inactive in roster
Here @alice covers both the @alice and @acme/payments groups, so she is chosen even though her expertise is low and her load high: the set is minimal first, scored second. @bram is on vacation, @carla is the author, @erin has left. See docs/scoring.md.
With --gitlab, section approval counts are honoured ([Payments][2] needs two distinct reviewers) and optional ^[Sections] require nothing:
2 changed file(s), 2 required owner group(s)
Required owner groups
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ Group (any one of) ┃ Files ┃ Eligible candidates ┃
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ @acme/platform │ 2 │ dmitri, farid │
│ @acme/payments │ 1 │ alice, carla │
└────────────────────┴───────┴─────────────────────┘
Suggested reviewers
@carla: covers @acme/payments. expertise 1.00 (23 decayed lines, last touched 19d ago), 0 open review(s),
score 1.00
@farid: covers @acme/platform. expertise 0.87 (20 decayed lines, last touched 13d ago), 0 open review(s),
score 0.87
@alice: covers @acme/payments. expertise 0.18 (4 decayed lines, last touched 262d ago), 0 open review(s),
score 0.18
Excluded @bram: unavailable until 2026-10-26 (vacation)
Excluded @erin: inactive in roster
generate
reviewroute generate [--depth 2] [--min-share 0.25] [--min-commits 2] [--max-owners 3] [--team-share 0.6] [--half-life 180] [--default-owner @org/team] [-o FILE]
# Generated by reviewroute 0.1.0 from git history (half-life 180d, min share 25%). Review before committing.
/.github/ @acme/platform # 100% dmitri
/apps/admin/ @acme/web # 50% ines, 30% gwen, 18% hiro
/apps/docs-site/ @acme/web # 44% hiro, 33% gwen, 22% ines
/apps/mobile-android/ @acme/mobile # 100% jonas
/apps/mobile-ios/ @acme/mobile # 93% jonas, 6% dmitri
/apps/web/ @acme/web # 34% hiro, 27% ines, 27% gwen
/data/ @acme/data # 40% liam, 37% mira, 8% noor
/docs/ @acme/web # 32% gwen, 28% ines, 23% hiro
/docs/runbooks/ @acme/platform # 100% dmitri
/infra/ @acme/platform # 42% farid, 38% dmitri, 10% liam
/legacy/billing-v1/ @acme/payments # 44% alice, 29% carla, 26% bram
/legacy/reports/ @acme/data # 47% mira, 41% liam, 10% dmitri
/libs/config/ @acme/platform # 100% farid
/libs/core/ @acme/platform @alice # 37% farid, 36% dmitri, 25% alice
/libs/db/ @acme/mobile @dmitri # 61% jonas, 38% dmitri
/libs/http/ @acme/platform # 63% farid, 36% dmitri
/libs/logging/ @acme/platform # 70% farid, 29% dmitri
/libs/testing/ @acme/platform # 100% dmitri
/security/ @acme/security # 100% noor
/services/ @acme/payments # 30% bram, 19% carla, 13% alice
/services/auth/ @acme/security # 100% noor
/services/gateway/ @acme/platform # 61% farid, 38% dmitri
/services/search/ @acme/data # 72% liam, 27% mira
/tests/ @acme/web # 32% ines, 27% hiro, 23% gwen
/tests/perf/ @acme/platform # 100% dmitri
/tools/ @acme/platform # 65% dmitri, 19% farid, 15% noor
Departed and non-roster authors are ignored, a directory collapses to a team when the team's members together reach --team-share, shallow directories without a clear owner defer to their children, and a child is only emitted when it differs from its parent.
Input files
owners.yaml
users:
alice: {name: Alice Ng, emails: [alice@acme.example], active: true}
erin: {name: Erin Walsh, emails: [erin@acme.example], active: false}
teams:
acme/payments: {members: [alice, bram, carla]}
Emails map git authors to logins. Teams are referenced in CODEOWNERS as @acme/payments.
unavailable.yaml
unavailable:
- {user: bram, from: 2026-10-13, until: 2026-10-26, reason: vacation}
load.csv: user,open_reviews per line.
Configuration
Everything is command-line options; the GitHub API is optional and needs GITHUB_TOKEN: lint --github-validate checks @user and @org/team owners when no roster is given, and suggest --github-repo owner/name counts requested reviewers on open pull requests. Tests never touch the network.
Architecture
| Module | Role |
|---|---|
patterns.py |
CODEOWNERS pattern to regex compiler, catch-all detection |
parser.py |
line parser, owners, GitLab sections, rule resolution |
ownership.py |
resolves every repo file against the rules once (hits, winners, per-rule matches) |
roster.py |
owners.yaml, unavailable.yaml, owner expansion |
history.py |
git helpers: file list, diff, log --numstat into change events |
lint.py |
all lint checks |
coverage.py |
coverage, hotspots, concentration, bus factor |
scoring.py |
exponential-decay expertise |
suggest.py |
required groups, greedy set cover, explanations |
generate.py |
CODEOWNERS draft generation |
github.py |
optional API access behind an injectable fetch |
cli.py |
click commands and rich/JSON/annotation output |
Deeper notes: docs/codeowners-semantics.md, docs/scoring.md.
Tests
.venv/bin/python -m pytest -q
Covers a pattern-matching table drawn from the GitHub docs, last-match-wins, GitLab sections, every lint code, coverage math, decay scoring, set cover (required groups covered, author and vacation excluded, approval counts), generation, the GitHub client with a fake transport, and the CLI end to end on the synthetic repo.
Limitations
- Pattern matching works on the files tracked at
HEAD; renames are not followed in history (--no-renames). - Team ownership is judged from roster membership; team privacy, nested teams and GitHub's "write access" requirement are not modelled.
suggestrequires--authorto exclude the PR author; it is not guessed.- GitLab support covers sections, approval counts, optional sections and default owners, but not
[Section]access-level syntax or per-section merge-request settings. - Churn and expertise count added plus deleted lines; huge generated files can skew them.