From e85ae285c96e77806329097639aa05a94d0e76c7 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 14 Aug 2026 23:11:01 -0700 Subject: [PATCH] ci: add weekly stale PR reminder Lists open PRs with no approving review that are older than five days and posts them to Slack on Monday mornings. Advisory only: it never comments on a PR, requests a review, or closes anything. Drafts and CHANGES_REQUESTED are excluded because neither is blocked on a reviewer. The filtering logic lives in .github/scripts/stale_prs.py rather than a heredoc in the workflow, matching operator_notes.py. CI runs `flake8 .` and `black --check .` from the repo root, so a script is linted where embedded YAML Python is not. Safe to merge before SLACK_STALE_PR_WEBHOOK exists: without the secret the job prints the list to the run log and exits clean. Co-Authored-By: Claude Opus 5 --- .github/scripts/stale_prs.py | 206 ++++++++++++++++++++++++++++++++ .github/workflows/stale-prs.yml | 88 ++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 .github/scripts/stale_prs.py create mode 100644 .github/workflows/stale-prs.yml diff --git a/.github/scripts/stale_prs.py b/.github/scripts/stale_prs.py new file mode 100644 index 00000000..c2b032bc --- /dev/null +++ b/.github/scripts/stale_prs.py @@ -0,0 +1,206 @@ +"""List open PRs that have been waiting on review, for a weekly nudge. + +A PR counts as waiting when it is not a draft, has no approving review, and +was opened at least `--days` ago. Drafts and changes-requested PRs are +excluded on the grounds that neither is blocked on a reviewer. + +Usage (in CI): + python .github/scripts/stale_prs.py --days 5 + +Emits the note on stdout and sets `has_stale` in $GITHUB_OUTPUT. Exits 0 with +no note when nothing is waiting, which is the goal state. +""" + +import argparse +import datetime as dt +import json +import os +import subprocess +import sys + +# Heredoc delimiter for the multi-line $GITHUB_OUTPUT value. +DELIMITER = "STALE_EOF" + +# reviewDecision values that mean nobody is waiting on a reviewer. +# +# Verified against slaclab/sc_linac_physics: because `main` is protected with +# required_approving_review_count = 1, GitHub always computes a decision, so +# unreviewed PRs come back as "REVIEW_REQUIRED" rather than "". A PR opened +# against an unprotected base branch does return "", and the field can be +# absent entirely. Testing for the excluded values rather than the included +# ones keeps all three cases ("REVIEW_REQUIRED", "", missing) reported. +NOT_WAITING = {"APPROVED", "CHANGES_REQUESTED"} + +# Fields to request. Keep this list minimal: `gh` builds its GraphQL query +# from exactly these, so an unused field is an extra permission surface. +FIELDS = ( + "number,title,author,createdAt,isDraft,reviewDecision,reviewRequests,url" +) + + +def fence_safe(text): + """Stop note text from closing the $GITHUB_OUTPUT heredoc early. + + GitHub ends a multi-line output at the first line matching the delimiter + exactly, so a bare `STALE_EOF` line could append arbitrary `key=value` + step outputs to this job. Indenting such a line keeps it inert. + + Nothing interpolated below can currently produce one -- PR titles, author + logins and team names are all single-line -- so this is defensive only. + It stops being defensive the moment anyone renders a PR body here. + """ + return "\n".join( + f" {line}" if line.strip() == DELIMITER else line + for line in text.splitlines() + ) + + +def slack_escape(text): + """Escape the three characters Slack treats as markup in `text` fields. + + Ampersand first, or the escapes introduced below get double-escaped. + """ + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def fetch_prs(repo, limit): + """Open PRs from the GitHub API, newest first. + + Surfaces gh's own stderr on failure. The likely first-run failure is the + token lacking a scope for reviewDecision or reviewRequests, and "Resource + not accessible by integration" says that; a bare traceback does not. + """ + cmd = ["gh", "pr", "list", "--repo", repo, "--state", "open"] + cmd += ["--limit", str(limit), "--json", FIELDS] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + sys.stderr.write(proc.stderr) + raise SystemExit( + f"gh pr list failed (exit {proc.returncode}); see stderr above" + ) + return json.loads(proc.stdout) + + +def reviewer_name(request): + """Display name for one reviewRequests entry. + + Users carry `login`. Teams carry `name` (display) and `slug` + (org-qualified, e.g. "slaclab/srf") but no `login`, so prefer the + shorter `name` and fall back to `slug`. + """ + return request.get("login") or request.get("name") or request.get("slug") + + +def waiting_on_review(pr, now, stale_days): + """A row dict if this PR is waiting on a reviewer, else None.""" + if pr.get("isDraft"): + return None + if pr.get("reviewDecision") in NOT_WAITING: + return None + + opened = dt.datetime.fromisoformat(pr["createdAt"].replace("Z", "+00:00")) + age = (now - opened).days + if age < stale_days: + return None + + reviewers = [ + name + for name in (reviewer_name(r) for r in pr.get("reviewRequests") or []) + if name + ] + return { + "number": pr["number"], + "title": pr["title"], + "author": (pr.get("author") or {}).get("login", "?"), + "age": age, + "reviewers": reviewers, + "url": pr["url"], + } + + +def render(rows, truncated): + """Slack mrkdwn for the collected rows.""" + plural = "s" if len(rows) > 1 else "" + lines = [f"*{len(rows)} PR{plural} waiting on review*", ""] + + for row in rows: + who = ", ".join(f"@{slack_escape(r)}" for r in row["reviewers"]) + who = who or "_no reviewer requested_" + lines.append( + f"• <{row['url']}|#{row['number']}> " + f"{slack_escape(row['title'])} — " + f"{row['age']}d, by {slack_escape(row['author'])}, " + f"waiting on {who}" + ) + + lines += [ + "", + "_Open PRs with no approving review. Drafts and " + "changes-requested excluded._", + ] + if truncated: + # gh returns newest-first and truncates silently at --limit, so the + # PRs dropped here are the oldest ones -- exactly the ones this job + # exists to surface. Say so rather than under-reporting quietly. + lines.append("_Hit the query limit; the oldest PRs may be missing._") + return "\n".join(lines) + + +def write_output(note): + """Set `has_stale` and `note` in $GITHUB_OUTPUT, when running in CI.""" + out_path = os.environ.get("GITHUB_OUTPUT") + if not out_path: + return + + with open(out_path, "a") as fh: + if note is None: + fh.write("has_stale=false\n") + return + fh.write("has_stale=true\n") + fh.write(f"note<<{DELIMITER}\n") + fh.write(fence_safe(note) + "\n") + fh.write(f"{DELIMITER}\n") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument( + "--days", + type=int, + default=5, + help="days without an approving review before a PR is listed", + ) + ap.add_argument( + "--repo", + default=os.environ.get("GITHUB_REPOSITORY"), + help="owner/name (defaults to $GITHUB_REPOSITORY)", + ) + ap.add_argument("--limit", type=int, default=100) + args = ap.parse_args() + + if not args.repo: + ap.error("--repo is required when $GITHUB_REPOSITORY is unset") + + prs = fetch_prs(args.repo, args.limit) + now = dt.datetime.now(dt.timezone.utc) + + rows = [ + row + for row in (waiting_on_review(p, now, args.days) for p in prs) + if row + ] + rows.sort(key=lambda r: -r["age"]) + + if not rows: + print(f"No PRs waiting on review beyond {args.days} days.") + write_output(None) + return 0 + + note = render(rows, truncated=len(prs) >= args.limit) + print(note) + write_output(note) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml new file mode 100644 index 00000000..dbc4678a --- /dev/null +++ b/.github/workflows/stale-prs.yml @@ -0,0 +1,88 @@ +name: Stale PR reminder + +# Weekly list of open PRs that have been waiting on review, posted to Slack. +# +# Runs on GitHub's infrastructure, so it has the API access that a local or +# sandboxed tool does not — review state and requested reviewers are only +# visible through the API, not through git refs. +# +# Advisory. It posts a list; it does not nag reviewers directly, comment on +# PRs, or close anything. Whether to chase someone stays a human decision. +# +# Needs a SLACK_STALE_PR_WEBHOOK repo secret. Point it wherever the nudge is +# most useful — a DM to yourself keeps you as the one who asks; #srf-software +# makes it visible to reviewers. Without the secret the job prints the list to +# the run log and exits clean, so this is safe to merge before wiring it up. +# +# PR titles are untrusted input. They reach Slack only through the `NOTE` +# environment variable below, never interpolated into a `run:` block. + +on: + schedule: + # Mondays 16:00 UTC = 09:00 America/Los_Angeles during PDT. + # GitHub cron is UTC only, so this drifts an hour in winter. Harmless. + - cron: '0 16 * * 1' + workflow_dispatch: + +# Read-only. stale_prs.py only ever calls `gh pr list`; nothing here writes to +# a PR. `contents: read` is for the checkout that fetches the script itself. +permissions: + contents: read + pull-requests: read + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Find PRs awaiting review + id: find + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + # A PR is listed once it has gone this many days without an approving + # review. Five working days is roughly a week of real time. + run: python .github/scripts/stale_prs.py --days 5 + + # Mirrors the release.yml notifier. Without the secret this prints the + # note to the job log and exits cleanly, so the workflow is safe to + # merge before the webhook exists. + # + # The channel is fixed by whichever channel SLACK_STALE_PR_WEBHOOK was + # created against, not by anything here. Named in the step title so the + # target is visible in the Actions UI rather than buried in a secret. + # Quoted: an unquoted '#' starts a YAML comment, which would truncate + # this step's name to "Post to" in the Actions UI. + - name: "Post to #srf-software" + if: steps.find.outputs.has_stale == 'true' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_STALE_PR_WEBHOOK }} + NOTE: ${{ steps.find.outputs.note }} + run: | + python - <<'PY' + import json, os, urllib.request + + url = os.environ.get("SLACK_WEBHOOK_URL", "").strip() + note = os.environ.get("NOTE", "").strip() + + if not note: + print("nothing to post") + raise SystemExit(0) + + if not url: + print("SLACK_STALE_PR_WEBHOOK not set — would have posted:\n") + print(note) + raise SystemExit(0) + + req = urllib.request.Request( + url, + data=json.dumps({"text": note}).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + print("slack responded", r.status) + PY + continue-on-error: true