Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Added
- `vouch reviewers` — a read-only throughput report keyed on the human at the
gate (`decided_by`), which `vouch stats` (bucketed by the proposing agent)
and `vouch digest` don't surface: per reviewer, approvals vs rejections,
approval rate, and turnaround from `proposed_at` to `decided_at` (median and
max). auto-expiries are a system action, not a review, so they are excluded
from reviewer counts and reported once as `expired_total`. `--since` window,
`--format text|json|markdown`; writes nothing.

## [1.2.2] — 2026-07-07

### Packaging
Expand Down
32 changes: 32 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from . import pr_cache as prc_mod
from . import provenance as prov_mod
from . import recall as recall_mod
from . import reviewers as reviewers_mod
from . import sessions as sess_mod
from . import stats as stats_mod
from . import sync as sync_mod
Expand Down Expand Up @@ -365,6 +366,37 @@ def digest_cmd(since: str, stale_days: int, limit: int, fmt: str) -> None:
click.echo(digest_mod.render_text(d))


@cli.command(name="reviewers")
@click.option(
"--since",
default=reviewers_mod.DEFAULT_SINCE_SPEC,
show_default=True,
help="Window: a duration (30d, 12h), an ISO date, or 'all'.",
)
@click.option(
"--format",
"fmt",
default="text",
show_default=True,
type=click.Choice(["text", "json", "markdown"]),
)
def reviewers_cmd(since: str, fmt: str) -> None:
"""Read-only throughput per human at the gate: decisions, approval rate,
and turnaround by decided_by. Writes nothing — safe to run from cron."""
store = _load_store()
try:
since_dt = metrics_mod.parse_since(since)
except metrics_mod.MetricsError as e:
raise click.UsageError(str(e)) from e
report = reviewers_mod.build(store, since=since_dt)
if fmt == "json":
_emit_json(report.to_dict())
elif fmt == "markdown":
click.echo(reviewers_mod.render_markdown(report))
else:
click.echo(reviewers_mod.render_text(report))


def _findings_json(report) -> list[dict[str, Any]]:
return [
{
Expand Down
191 changes: 191 additions & 0 deletions src/vouch/reviewers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Read-only reviewer-throughput report: who works the gate, and how.

`vouch stats` buckets the review queue by the *proposing* agent (`proposed_by`)
and `vouch digest` lists recent decisions one at a time. Neither aggregates by
the human at the gate — the `decided_by` actor who actually approves or rejects.
This viewport does: per reviewer, how many proposals they approved versus
rejected, their approval rate, and how long proposals waited before they decided
(turnaround, from `proposed_at` to `decided_at`).

An auto-expiry is not a review decision — a proposal that ages out is closed by
the `vouch-expire` actor, not a human — so expiries are excluded from every
reviewer's counts and reported once, separately, as `expired_total`.

Strictly a viewport: it composes `store.list_proposals`, writes nothing, logs no
audit event, and never touches a proposal — there is nothing here for the review
gate to gate.
"""

from __future__ import annotations

import statistics
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Any

from .models import ProposalStatus
from .proposals import EXPIRE_REASON
from .storage import KBStore

DEFAULT_SINCE_SPEC = "30d"

_UNKNOWN_REVIEWER = "(unknown)"


@dataclass(frozen=True)
class ReviewerRow:
reviewer: str
decisions: int
approved: int
rejected: int
approval_rate: float | None
turnaround_hours_median: float | None
turnaround_hours_max: float | None


@dataclass(frozen=True)
class ReviewerReport:
"""Stable `to_dict()` schema — the `--format json` contract."""

generated_at: str
since: str | None
reviewers_total: int
decisions_total: int
expired_total: int
reviewers: list[ReviewerRow] = field(default_factory=list)

def to_dict(self) -> dict[str, Any]:
return asdict(self)


def _as_utc(dt: datetime | None) -> datetime | None:
if dt is None:
return None
return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC)


@dataclass
class _Acc:
approved: int = 0
rejected: int = 0
turnarounds: list[float] = field(default_factory=list)


def build(
store: KBStore,
*,
since: datetime | None = None,
now: datetime | None = None,
) -> ReviewerReport:
"""Compose the reviewer-throughput report. Read-only by construction."""
now = _as_utc(now) or datetime.now(UTC)
since = _as_utc(since)

accs: dict[str, _Acc] = {}
decisions_total = 0
expired_total = 0

decided = [
*store.list_proposals(ProposalStatus.APPROVED),
*store.list_proposals(ProposalStatus.REJECTED),
]
for p in decided:
decided_at = _as_utc(p.decided_at)
if decided_at is None:
# An APPROVED/REJECTED proposal with no decided_at can't be placed
# in the window or attributed in time — skip rather than guess.
continue
if since is not None and decided_at < since:
continue
if p.decision_reason == EXPIRE_REASON:
expired_total += 1
continue

decisions_total += 1
acc = accs.setdefault(p.decided_by or _UNKNOWN_REVIEWER, _Acc())
if p.status == ProposalStatus.APPROVED:
acc.approved += 1
else:
acc.rejected += 1

proposed_at = _as_utc(p.proposed_at)
if proposed_at is not None and decided_at >= proposed_at:
acc.turnarounds.append((decided_at - proposed_at).total_seconds() / 3600.0)

rows = [
ReviewerRow(
reviewer=reviewer,
decisions=acc.approved + acc.rejected,
approved=acc.approved,
rejected=acc.rejected,
approval_rate=(
round(acc.approved / (acc.approved + acc.rejected), 4)
if (acc.approved + acc.rejected)
else None
),
turnaround_hours_median=(
round(statistics.median(acc.turnarounds), 2) if acc.turnarounds else None
),
turnaround_hours_max=(
round(max(acc.turnarounds), 2) if acc.turnarounds else None
),
)
for reviewer, acc in accs.items()
]
rows.sort(key=lambda r: (-r.decisions, r.reviewer))

return ReviewerReport(
generated_at=now.isoformat(timespec="seconds"),
since=since.isoformat(timespec="seconds") if since else None,
reviewers_total=len(rows),
decisions_total=decisions_total,
expired_total=expired_total,
reviewers=rows,
)


def _rate(rate: float | None) -> str:
return f"{rate:.0%}" if rate is not None else "n/a"


def _hours(value: float | None) -> str:
return f"{value:g}h" if value is not None else "n/a"


def render_text(report: ReviewerReport) -> str:
lines = [
f"reviewer throughput @ {report.generated_at} "
f"(window since: {report.since or 'all'})",
f"reviewers: {report.reviewers_total} decisions: {report.decisions_total} "
f"expired (excluded): {report.expired_total}",
"",
]
for r in report.reviewers:
lines.append(
f" {r.reviewer} {r.decisions} decision(s) "
f"{r.approved} approved / {r.rejected} rejected "
f"approval {_rate(r.approval_rate)} "
f"turnaround med {_hours(r.turnaround_hours_median)} / "
f"max {_hours(r.turnaround_hours_max)}"
)
if not report.reviewers:
lines.append(" none")
return "\n".join(lines)


def render_markdown(report: ReviewerReport) -> str:
lines = [
f"# reviewer throughput — {report.generated_at}",
"",
f"window since: {report.since or 'all'} · reviewers: {report.reviewers_total} · "
f"decisions: {report.decisions_total} · expired (excluded): {report.expired_total}",
"",
]
lines += [
f"- **{r.reviewer}** — {r.decisions} decision(s), {r.approved} approved / "
f"{r.rejected} rejected, approval {_rate(r.approval_rate)}, "
f"turnaround med {_hours(r.turnaround_hours_median)} / "
f"max {_hours(r.turnaround_hours_max)}"
for r in report.reviewers
] or ["- none"]
return "\n".join(lines)
Loading