diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml new file mode 100644 index 0000000..5e634ba --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,13 @@ +# Dogfooding: PRs in this repo get the UNREVIEWED label too. +# Other repos use templates/pr-label.yml (pinned @master); this one references +# the reusable workflow relatively so it works on unmerged branches as well. +name: PR label +on: + pull_request: + types: [opened, reopened] +permissions: + pull-requests: write + issues: write +jobs: + label: + uses: ./.github/workflows/reusable-pr-label.yml diff --git a/.github/workflows/pr-reminder.yml b/.github/workflows/pr-reminder.yml new file mode 100644 index 0000000..6230d54 --- /dev/null +++ b/.github/workflows/pr-reminder.yml @@ -0,0 +1,46 @@ +name: PR review reminders + +# Weekday-morning cron: DMs reviewers/authors on Slack about open PRs that +# still carry the UNREVIEWED label. See scripts/pr_reminder.py and the +# "PR hygiene" section in the README. + +on: + schedule: + - cron: '17 6 * * 1-5' # ~08:17 Zurich in summer, ~07:17 in winter + workflow_dispatch: + +permissions: + contents: read # actions/checkout + actions: write # keepalive step re-enables this workflow + +jobs: + remind: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Send reminders + env: + # Test phase: fine-grained PAT. Production: replace with a GitHub App + # token via actions/create-github-app-token (see README) — only this + # env line changes. + GH_TOKEN: ${{ secrets.PR_BOT_PAT }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + GH_SLACK_MAP: ${{ vars.GH_SLACK_MAP }} + DRY_RUN: ${{ vars.PR_HYGIENE_DRY_RUN }} + ALLOWLIST: ${{ vars.PR_HYGIENE_ALLOWLIST }} + run: python3 scripts/pr_reminder.py + + - name: Notify failure on Slack + if: failure() + continue-on-error: true + uses: dreipol/github-actions/slack@master + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + TEXT: ':boom: PR review reminder run failed. ' + + - name: Keepalive (public repo crons are disabled after 60 idle days) + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: gh api -X PUT "repos/${{ github.repository }}/actions/workflows/pr-reminder.yml/enable" diff --git a/.github/workflows/reusable-pr-label.yml b/.github/workflows/reusable-pr-label.yml new file mode 100644 index 0000000..d04e9ec --- /dev/null +++ b/.github/workflows/reusable-pr-label.yml @@ -0,0 +1,32 @@ +name: Reusable PR label + +# Applies the UNREVIEWED label to a pull request. +# The label is removed MANUALLY by the reviewer after reviewing — that removal +# is the "reviewed" acknowledgment. This workflow never removes or re-adds it. +# +# Consumers call this from a small workflow file, see templates/pr-label.yml. +# Required caller permissions: pull-requests: write, issues: write + +on: + workflow_call: + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Ensure UNREVIEWED label exists + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create UNREVIEWED \ + --color B22D47 \ + --description "No review yet — remove this label after reviewing" \ + --force \ + --repo "${{ github.repository }}" + + - name: Add UNREVIEWED label to PR + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" \ + -f "labels[]=UNREVIEWED" diff --git a/docs/slack-app-manifest.yml b/docs/slack-app-manifest.yml new file mode 100644 index 0000000..a8df31f --- /dev/null +++ b/docs/slack-app-manifest.yml @@ -0,0 +1,22 @@ +# Slack app manifest for the PR review reminder bot. +# A Slack workspace admin creates the app at https://api.slack.com/apps +# ("Create New App" -> "From an app manifest"), installs it to the workspace, +# and stores the resulting bot token (xoxb-...) as the SLACK_BOT_TOKEN secret +# in dreipol/github-actions. +display_information: + name: PR Review Reminder + description: DMs you about pull requests waiting for your review + background_color: "#B22D47" +features: + bot_user: + display_name: pr-review-reminder + always_online: false +oauth_config: + scopes: + bot: + - chat:write + - im:write +settings: + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false diff --git a/scripts/pr_reminder.py b/scripts/pr_reminder.py new file mode 100755 index 0000000..0d85275 --- /dev/null +++ b/scripts/pr_reminder.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Slack reminders for PRs carrying the UNREVIEWED label. + +Searches all open PRs in the org labeled UNREVIEWED, and DMs the requested +reviewers (or the author, if nobody was asked to review) on Slack. + +Cadence: nag #1 on the first weekday morning after the label was applied, +then daily, hard stop after nag #3. Stateless — the nag number is derived +from the timestamp of the (latest) UNREVIEWED "labeled" event on the PR. +Removing the label (done manually by the reviewer) stops the reminders. + +Environment: + GH_TOKEN GitHub token with org-wide PR read access (PAT or App token) + SLACK_BOT_TOKEN Slack bot token (scopes: chat:write, im:write) + GH_SLACK_MAP JSON object {"github_login": "U_SLACK_MEMBER_ID", ...} + DRY_RUN "false" enables real Slack sends; anything else = dry run + ALLOWLIST optional JSON array of GitHub logins; if non-empty, only + these users receive DMs (pilot phase) + GH_ORG organization to search (default: dreipol) +""" + +import json +import os +import sys +import time +import urllib.parse +import urllib.request +from datetime import date, datetime, timezone +from zoneinfo import ZoneInfo + +GITHUB_API = "https://api.github.com" +SLACK_API = "https://slack.com/api/chat.postMessage" +LABEL = "UNREVIEWED" +MAX_NAGS = 3 +LOCAL_TZ = ZoneInfo("Europe/Zurich") +REQUEST_TIMEOUT = 15 # seconds; a hung connection must not stall the whole run + + +def github_request(path, token, params=None): + url = f"{GITHUB_API}{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + request = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + return json.load(response) + + +def github_paginate(path, token, params=None, items_key=None): + page = 1 + while True: + page_params = dict(params or {}, per_page=100, page=page) + data = github_request(path, token, page_params) + items = data[items_key] if items_key else data + yield from items + if len(items) < 100: + return + page += 1 + + +def search_unreviewed_prs(org, token): + query = f"org:{org} is:pr is:open label:{LABEL}" + return list(github_paginate("/search/issues", token, {"q": query}, items_key="items")) + + +def label_anchor(repo, number, token): + """Timestamp of the latest UNREVIEWED 'labeled' event, or None.""" + latest = None + for event in github_paginate(f"/repos/{repo}/issues/{number}/timeline", token): + if event.get("event") == "labeled" and event.get("label", {}).get("name") == LABEL: + created = event.get("created_at") + if created and (latest is None or created > latest): + latest = created + if latest is None: + return None + return datetime.fromisoformat(latest.replace("Z", "+00:00")) + + +def nag_number(anchor_date, today): + """Count of weekdays d with anchor_date < d <= today. + + Labeled Monday -> Tuesday run = 1; labeled Friday -> Monday run = 1 + (weekends don't count); values above MAX_NAGS mean: stay silent. + """ + count = 0 + day = anchor_date + while day < today: + day = date.fromordinal(day.toordinal() + 1) + if day.weekday() < 5: + count += 1 + return count + + +def pr_targets(repo, number, author, token): + """(logins, is_author_fallback) — requested reviewers, else the author.""" + pull = github_request(f"/repos/{repo}/pulls/{number}", token) + reviewers = [user["login"] for user in pull.get("requested_reviewers", [])] + for team in pull.get("requested_teams", []): + print(f"SKIP team reviewer '{team['slug']}' on {repo}#{number} (teams unsupported)") + if reviewers: + return reviewers, False + return [author], True + + +def format_dm(entries): + lines = [ + "👋 You have pull requests waiting for review:", + "", + ] + for entry in entries: + nag = entry["nag"] + prefix = "🔴 *Final reminder:* " if nag == MAX_NAGS else "" + age = f"{nag} weekday{'s' if nag != 1 else ''} unreviewed" + lines.append(f"{prefix}<{entry['url']}|{entry['title']}> ({entry['repo']}, {age})") + if entry["author_fallback"]: + lines.append(" ↳ your PR has *no reviewer assigned* — please request one") + lines += [ + "", + "_Review the PR, then remove the `UNREVIEWED` label to stop these reminders._", + ] + return "\n".join(lines) + + +def send_dm(slack_id, text, token): + payload = json.dumps({"channel": slack_id, "text": text}).encode() + request = urllib.request.Request(SLACK_API, data=payload, headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + }) + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + result = json.load(response) + if not result.get("ok"): + raise RuntimeError(f"Slack error for {slack_id}: {result.get('error')}") + + +def main(): + gh_token = os.environ["GH_TOKEN"] + org = os.environ.get("GH_ORG", "dreipol") + dry_run = os.environ.get("DRY_RUN", "true").lower() != "false" + slack_map = json.loads(os.environ.get("GH_SLACK_MAP") or "{}") + allowlist = json.loads(os.environ.get("ALLOWLIST") or "[]") + + today = datetime.now(LOCAL_TZ).date() + prs = search_unreviewed_prs(org, gh_token) + print(f"Found {len(prs)} open PRs with label {LABEL} (dry_run={dry_run})") + + queue = {} # github login -> list of PR entries + for pr in prs: + repo = pr["repository_url"].removeprefix(f"{GITHUB_API}/repos/") + number = pr["number"] + anchor = label_anchor(repo, number, gh_token) + if anchor is None: + print(f"SKIP {repo}#{number}: no {LABEL} labeled event found") + continue + nag = nag_number(anchor.astimezone(LOCAL_TZ).date(), today) + if nag == 0: + print(f"SKIP {repo}#{number}: labeled today, first nag tomorrow") + continue + if nag > MAX_NAGS: + print(f"SKIP {repo}#{number}: past nag #{MAX_NAGS}, staying silent") + continue + targets, author_fallback = pr_targets(repo, number, pr["user"]["login"], gh_token) + for login in targets: + queue.setdefault(login, []).append({ + "repo": repo, "url": pr["html_url"], "title": pr["title"], + "nag": nag, "author_fallback": author_fallback, + }) + + failures = 0 + for login, entries in sorted(queue.items()): + summary = ", ".join(f"{e['repo']}#{e['url'].rsplit('/', 1)[1]} (nag {e['nag']})" for e in entries) + if allowlist and login not in allowlist: + print(f"SKIP {login}: not on allowlist — {summary}") + continue + slack_id = slack_map.get(login) + if not slack_id: + print(f"SKIP {login}: no Slack mapping in GH_SLACK_MAP — {summary}") + continue + if dry_run: + print(f"DRY-RUN would DM {login} ({slack_id}): {summary}") + continue + try: + send_dm(slack_id, format_dm(entries), os.environ["SLACK_BOT_TOKEN"]) + print(f"SENT DM to {login} ({slack_id}): {summary}") + time.sleep(1) # chat.postMessage: ~1 msg/sec + except Exception as error: + print(f"ERROR DMing {login}: {error}") + failures += 1 + + if failures: + sys.exit(f"{failures} Slack DM(s) failed") + + +if __name__ == "__main__": + main() diff --git a/templates/pr-label.yml b/templates/pr-label.yml new file mode 100644 index 0000000..e0b8787 --- /dev/null +++ b/templates/pr-label.yml @@ -0,0 +1,14 @@ +# Copy this file into your repo as .github/workflows/pr-label.yml +# It labels every new PR with UNREVIEWED. Remove the label manually after +# you reviewed the PR — reminders stop once the label is gone (PR closed/merged) +# or removed. +name: PR label +on: + pull_request: + types: [opened, reopened] +permissions: + pull-requests: write + issues: write +jobs: + label: + uses: dreipol/github-actions/.github/workflows/reusable-pr-label.yml@master