Skip to content
Open
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
206 changes: 206 additions & 0 deletions .github/scripts/stale_prs.py
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


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())
88 changes: 88 additions & 0 deletions .github/workflows/stale-prs.yml
Original file line number Diff line number Diff line change
@@ -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