From 30f6905426ed63040525ac6cdc7b052d449eb88e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:17:17 +0900 Subject: [PATCH] feat(pr-bot): auto-close prs stale 2 days after a change request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add a daily scheduled stale-pr-reaper that closes an open pr whose author left CodeRabbit's change request unaddressed — no new commit for 2 days. it complements the 3-strikes close: strikes fires when the author keeps pushing failing commits, this fires when they go silent. the decision (should_close_stale) lives in pr_bot.py as pure stdlib and is scoped to the pr head commit via each review's commit_id, so a push after the review moves head off the reviewed sha and resets the clock. the owner and bots are exempt, matching the strikes exemption. the reaper reads only api metadata and runs from the trusted default branch — no pr head code runs. scheduled workflows only fire from the default branch, so it activates once it reaches main. --- .github/workflows/stale-pr-reaper.yml | 51 +++++++++++++++++++++++ CHANGELOG.md | 6 ++- src/vouch/pr_bot.py | 51 +++++++++++++++++++++++ tests/test_pr_bot.py | 60 +++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/stale-pr-reaper.yml 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"