Skip to content
Merged
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
12 changes: 5 additions & 7 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
# CodeRabbit configuration — https://docs.coderabbit.ai/guides/configure-coderabbit
# CodeRabbit is the required review gate for vouch (free for this public repo). it
# reviews every non-draft PR automatically. request_changes_workflow is on, so it
# submits a formal approve / request-changes review; the coderabbit-gate workflow
# turns that verdict into the required `coderabbit-approved` status check, so a pr
# only auto-merges once CodeRabbit approves (on top of ci + trust-gate + CODEOWNERS,
# with the owner's auto-merge label as the go signal). a pr CodeRabbit requests
# changes on 3 times is auto-closed (the owner and bots are exempt).
# CodeRabbit reviews every non-draft PR automatically (free for this public repo).
# its verdict is advisory: it gates nothing and closes nothing. the merge path is
# ci + trust-gate + CODEOWNERS, with the owner's auto-merge label as the go signal.
# request_changes_workflow stays on so its stance is legible at a glance, but a
# request-changes review no longer blocks or reaps a pr.
language: "en-US"
early_access: false
reviews:
Expand Down
87 changes: 0 additions & 87 deletions .github/workflows/coderabbit-gate.yml

This file was deleted.

51 changes: 0 additions & 51 deletions .github/workflows/stale-pr-reaper.yml

This file was deleted.

10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ All notable changes to vouch are documented here. Format follows
markers; absolute bench scores shift, paired comparisons were fair
either way. the reference baseline table is refreshed.
### Changed
- **CodeRabbit's verdict no longer gates anything.** the
`coderabbit-approved` commit status, the 3-strike auto-close, and the
daily stale-pr reaper are removed, along with the `coderabbit-gate` and
`stale-check` pr_bot commands that computed them. the status had
already been dropped from the `test` ruleset's required checks, so this
removes the machinery that outlived it rather than lowering a live bar.
CodeRabbit still reviews every non-draft pr and still files formal
approve / request-changes reviews — they are advisory now. the merge
path is ci + trust-gate + CODEOWNERS, with the owner's auto-merge label
as the go signal.
- **auto approval is the default** (`review.approver_role: trusted-agent`
in the starter config): a fresh KB approves the capturing agent's
proposals with no human step. nothing bypasses the gate — every write
Expand Down
134 changes: 2 additions & 132 deletions src/vouch/pr_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
call ``python -m vouch.pr_bot <subcommand>`` for every decision that must be
trustworthy: an author's trust tier, whether a PR touches core/ui paths, whether
a UI PR carries before/after screenshots, and whether a labeled PR may arm
native auto-merge. CodeRabbit is the review gate and runs as a GitHub App, not
here — this module only turns its verdict into the required `coderabbit-approved`
commit status and the deterministic calls that gate the merge.
native auto-merge. CodeRabbit runs as a GitHub App and still comments on PRs,
but its verdict no longer gates anything — nothing here reads it.
"""
from __future__ import annotations

Expand All @@ -15,7 +14,6 @@
import re
import sys
from collections.abc import Iterable, Mapping, Sequence
from datetime import UTC, datetime
from typing import Any

# the review-gate core: writes here are the north star. mirrored verbatim in
Expand Down Expand Up @@ -48,19 +46,6 @@
_OWNER_ASSOCIATION = "OWNER"
_BOT_ACTORS = frozenset({"dependabot[bot]"})

# CodeRabbit is the required review gate (.coderabbit.yaml). only reviews it
# authors on github count; anyone else's approval never satisfies the gate.
CODERABBIT_LOGIN = "coderabbitai[bot]"

# a contributor gets STRIKE_LIMIT rounds of "changes requested" from CodeRabbit
# before the pr is auto-closed. the owner and bots are exempt (author_is_exempt).
STRIKE_LIMIT = 3

# a pr whose author leaves CodeRabbit's change request unaddressed (no new
# commit) for STALE_DAYS is auto-closed by the scheduled stale-pr-reaper.
STALE_DAYS = 2
_EXEMPT_AUTHORS = frozenset({"plind-junior"}) | _BOT_ACTORS


def _match(path: str, glob: str) -> bool:
g = glob.lstrip("/")
Expand Down Expand Up @@ -115,91 +100,6 @@ def should_arm_automerge(*, is_core: bool, ci_passing: bool,
return claude_verdict == "APPROVE"


def _cr_verdicts(reviews: Sequence[Mapping[str, Any]], *,
login: str) -> list[tuple[str, Any]]:
"""(state, commit_id) for CodeRabbit reviews carrying a verdict.

COMMENTED and DISMISSED reviews carry no verdict and are dropped.
"""
out: list[tuple[str, Any]] = []
for r in reviews:
if (r.get("user") or {}).get("login") != login:
continue
state = str(r.get("state") or "").upper()
if state in ("APPROVED", "CHANGES_REQUESTED"):
out.append((state, r.get("commit_id")))
return out


def coderabbit_verdict(reviews: Sequence[Mapping[str, Any]], *,
head_sha: str | None = None,
login: str = CODERABBIT_LOGIN) -> tuple[str, int]:
"""CodeRabbit's (verdict, strikes) for a pr's review list.

``verdict`` is its stance on ``head_sha`` — 'approved', 'changes', or
'pending' when it has not yet reviewed that commit (so a fresh push voids
a prior approval). ``strikes`` counts the distinct commits it has requested
changes on, i.e. failed review rounds, across the pr's whole history.
"""
verdicts = _cr_verdicts(reviews, login=login)
strikes = len({cid for state, cid in verdicts if state == "CHANGES_REQUESTED"})
scoped = [v for v in verdicts if head_sha is None or v[1] == head_sha]
if not scoped:
return "pending", strikes
return ("approved" if scoped[-1][0] == "APPROVED" else "changes"), strikes


def gate_status(verdict: str) -> str:
"""Commit-status state for the required `coderabbit-approved` check."""
return {"approved": "success", "changes": "failure"}.get(verdict, "pending")


def author_is_exempt(author: str) -> bool:
"""The owner and bots are never auto-closed for failed reviews."""
return author in _EXEMPT_AUTHORS


def should_close(verdict: str, strikes: int, *, author: str,
limit: int = STRIKE_LIMIT) -> bool:
"""Auto-close a contributor pr CodeRabbit has rejected `limit` rounds."""
return (not author_is_exempt(author)
and verdict == "changes"
and strikes >= limit)


def _iso_epoch(s: str) -> float:
"""Epoch seconds for a github ISO8601 timestamp (e.g. 2026-07-15T10:20:30Z)."""
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp()


def should_close_stale(reviews: Sequence[Mapping[str, Any]], *, head_sha: str,
now_epoch: float, author: str, days: int = STALE_DAYS,
login: str = CODERABBIT_LOGIN) -> bool:
"""Auto-close a pr whose author left a CodeRabbit change request unaddressed.

Fires only when CodeRabbit's latest verdict *on the current head* is
"changes requested" and that review is >= ``days`` old — i.e. no new commit
has landed since (a push would move ``head_sha`` off the review's
``commit_id``). the owner and bots are exempt.
"""
if author_is_exempt(author):
return False
on_head = [r for r in reviews
if (r.get("user") or {}).get("login") == login
and r.get("commit_id") == head_sha
and str(r.get("state") or "").upper() in ("APPROVED", "CHANGES_REQUESTED")]
if not on_head or str(on_head[-1].get("state") or "").upper() != "CHANGES_REQUESTED":
return False
submitted = on_head[-1].get("submitted_at")
if not submitted:
return False
age_days = (now_epoch - _iso_epoch(str(submitted))) / 86400.0
return age_days >= days


def _read_lines(path: str) -> list[str]:
with open(path, encoding="utf-8") as fh:
return [ln.strip() for ln in fh if ln.strip()]
Expand Down Expand Up @@ -338,17 +238,6 @@ def main(argv: Sequence[str] | None = None) -> int:
a.add_argument("--verdict", required=True)
a.add_argument("--draft", action="store_true")

g = sub.add_parser("coderabbit-gate")
g.add_argument("--reviews-file", required=True)
g.add_argument("--head-sha", required=True)
g.add_argument("--author", required=True)

st = sub.add_parser("stale-check")
st.add_argument("--reviews-file", required=True)
st.add_argument("--head-sha", required=True)
st.add_argument("--author", required=True)
st.add_argument("--now-epoch", required=True, type=int)

ns = p.parse_args(argv)

if ns.cmd == "classify":
Expand Down Expand Up @@ -380,25 +269,6 @@ def main(argv: Sequence[str] | None = None) -> int:
ok = should_arm_automerge(is_core=c2["is_core"], ci_passing=ns.ci == "passing",
claude_verdict=ns.verdict, is_draft=ns.draft)
return 0 if ok else 1
if ns.cmd == "coderabbit-gate":
with open(ns.reviews_file, encoding="utf-8") as fh:
loaded = json.load(fh)
reviews = loaded if isinstance(loaded, list) else []
verdict, strikes = coderabbit_verdict(reviews, head_sha=ns.head_sha)
close = should_close(verdict, strikes, author=ns.author)
sys.stdout.write(
f"state={gate_status(verdict)}\n"
f"verdict={verdict}\n"
f"strikes={strikes}\n"
f"close={'true' if close else 'false'}\n")
return 0
if ns.cmd == "stale-check":
with open(ns.reviews_file, encoding="utf-8") as fh:
loaded = json.load(fh)
reviews = loaded if isinstance(loaded, list) else []
stale = should_close_stale(reviews, head_sha=ns.head_sha,
now_epoch=ns.now_epoch, author=ns.author)
return 0 if stale else 1
return 2


Expand Down
Loading
Loading