diff --git a/.github/workflows/stale-pr-reaper.yml b/.github/workflows/stale-pr-reaper.yml new file mode 100644 index 00000000..5ef2eeea --- /dev/null +++ b/.github/workflows/stale-pr-reaper.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d405b36e..1f55e02a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/vouch/pr_bot.py b/src/vouch/pr_bot.py index 2f735c8c..02d73e29 100644 --- a/src/vouch/pr_bot.py +++ b/src/vouch/pr_bot.py @@ -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 @@ -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 @@ -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()] @@ -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": @@ -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 diff --git a/tests/test_pr_bot.py b/tests/test_pr_bot.py index 34a2b9e8..c9976dca 100644 --- a/tests/test_pr_bot.py +++ b/tests/test_pr_bot.py @@ -1,5 +1,6 @@ import subprocess import sys +from datetime import UTC, datetime from pathlib import Path from vouch import pr_bot @@ -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"