# 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 PATH` explains 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

```bash
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
```

Requires Python 3.12+, git on `PATH`.

## Quickstart

```bash
.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](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`
```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`
```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/codeowners-semantics.md), [docs/scoring.md](docs/scoring.md).

## Tests

```bash
.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.
- `suggest` requires `--author` to 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.
