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
51 changes: 51 additions & 0 deletions .github/workflows/stale-pr-reaper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: stale-pr-reaper
# daily reaper: closes a pr whose author left CodeRabbit's change request
# unaddressed (no new commit) for 2 days. complements the 3-strikes close in
# coderabbit-gate.yml — that fires when the author keeps pushing failing
# commits, this fires when they go silent. scheduled workflows only run from the
# default branch, so this activates once it is on `main`. it reads only api
# metadata and runs from the trusted default branch — no pr head code is run.
on:
schedule:
- cron: "17 6 * * *" # daily ~06:17 UTC
workflow_dispatch: {}
permissions: {}
concurrency:
group: stale-pr-reaper
cancel-in-progress: false
jobs:
reap:
runs-on: ubuntu-latest
permissions:
contents: read # checkout + run pr_bot
pull-requests: write # comment + close
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: close prs stale for 2 days after a change request
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
now=$(date -u +%s)
gh pr list --repo "$REPO" --state open --limit 100 \
--json number,headRefOid,author,isDraft \
--jq '.[] | select(.isDraft|not) | [.number, .headRefOid, .author.login] | @tsv' \
> prs.tsv
while IFS=$'\t' read -r num sha author; do
[ -z "$num" ] && continue
gh api "repos/$REPO/pulls/$num/reviews?per_page=100" > reviews.json
if PYTHONPATH=src python -m vouch.pr_bot stale-check \
--reviews-file reviews.json --head-sha "$sha" \
--author "$author" --now-epoch "$now"; then
echo "reaping #$num (stale after change request)"
gh pr merge "$num" --repo "$REPO" --disable-auto || true
gh pr edit "$num" --repo "$REPO" --remove-label auto-merge || true
gh pr close "$num" --repo "$REPO" --comment \
"closing automatically: CodeRabbit requested changes and this pr has had no new commits for 2 days. the feedback still stands — push a fix and reopen this pr (or open a fresh one) and it will be reviewed again."
fi
done < prs.tsv
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ All notable changes to vouch are documented here. Format follows
the `coderabbit-gate` workflow turns its verdict into the required
`coderabbit-approved` status check — so a fresh push voids a prior approval,
and a pr CodeRabbit requests changes on 3 times is auto-closed (owner and
bots exempt). no paid model in the loop. deterministic decision logic lives
in `src/vouch/pr_bot.py` (pure stdlib). repo guards (branch ruleset + labels)
bots exempt). a daily `stale-pr-reaper` also closes a pr whose author left a
CodeRabbit change request unaddressed (no new commit) for 2 days. no paid
model in the loop. deterministic decision logic lives in
`src/vouch/pr_bot.py` (pure stdlib). repo guards (branch ruleset + labels)
are configured by `scripts/setup_repo_guards.sh`. workflow security is linted
in ci with zizmor + actionlint.

Expand Down
51 changes: 51 additions & 0 deletions src/vouch/pr_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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 @@ -54,6 +55,10 @@
# 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


Expand Down Expand Up @@ -162,6 +167,39 @@ def should_close(verdict: str, strikes: int, *, author: str,
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 @@ -197,6 +235,12 @@ def main(argv: Sequence[str] | None = None) -> int:
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 @@ -229,6 +273,13 @@ def main(argv: Sequence[str] | None = None) -> int:
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
60 changes: 60 additions & 0 deletions tests/test_pr_bot.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path

from vouch import pr_bot
Expand Down Expand Up @@ -164,6 +165,65 @@ def test_should_close_exempts_owner_and_bots():
assert pr_bot.should_close("changes", 9, author="dependabot[bot]") is False


def _iso(epoch):
return datetime.fromtimestamp(epoch, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def _review_ts(state, sha, epoch, login="coderabbitai[bot]"):
return {"user": {"login": login}, "state": state, "commit_id": sha,
"submitted_at": _iso(epoch)}


_NOW = 1_700_000_000


def test_stale_closes_after_two_days():
reviews = [_review_ts("CHANGES_REQUESTED", "head", _NOW - 3 * 86400)]
assert pr_bot.should_close_stale(
reviews, head_sha="head", now_epoch=_NOW, author="rando") is True


def test_stale_not_within_two_days():
reviews = [_review_ts("CHANGES_REQUESTED", "head", _NOW - 1 * 86400)]
assert pr_bot.should_close_stale(
reviews, head_sha="head", now_epoch=_NOW, author="rando") is False


def test_stale_ignored_when_author_pushed_a_new_commit():
# the change request is on an old commit; head moved on and has no review.
reviews = [_review_ts("CHANGES_REQUESTED", "old", _NOW - 5 * 86400)]
assert pr_bot.should_close_stale(
reviews, head_sha="new", now_epoch=_NOW, author="rando") is False


def test_stale_never_when_approved_on_head():
reviews = [_review_ts("APPROVED", "head", _NOW - 5 * 86400)]
assert pr_bot.should_close_stale(
reviews, head_sha="head", now_epoch=_NOW, author="rando") is False


def test_stale_exempts_owner_and_bots():
reviews = [_review_ts("CHANGES_REQUESTED", "head", _NOW - 9 * 86400)]
assert pr_bot.should_close_stale(
reviews, head_sha="head", now_epoch=_NOW, author="plind-junior") is False
assert pr_bot.should_close_stale(
reviews, head_sha="head", now_epoch=_NOW, author="dependabot[bot]") is False


def test_cli_stale_check_exit_codes(tmp_path):
import json as _json
f = tmp_path / "reviews.json"
f.write_text(_json.dumps(
[_review_ts("CHANGES_REQUESTED", "head", _NOW - 3 * 86400)]), encoding="utf-8")
stale = subprocess.run(
[sys.executable, "-m", "vouch.pr_bot", "stale-check", "--reviews-file", str(f),
"--head-sha", "head", "--author", "rando", "--now-epoch", str(_NOW)])
fresh = subprocess.run(
[sys.executable, "-m", "vouch.pr_bot", "stale-check", "--reviews-file", str(f),
"--head-sha", "head", "--author", "rando", "--now-epoch", str(_NOW - 3 * 86400)])
assert stale.returncode == 0 and fresh.returncode == 1


def test_cli_coderabbit_gate_outputs(tmp_path):
import json as _json
f = tmp_path / "reviews.json"
Expand Down
Loading