From 60cd7c4b06599c7d8fd7d30da81053b5d8347770 Mon Sep 17 00:00:00 2001 From: Severin Demchuk Date: Mon, 13 Jul 2026 09:43:47 +0200 Subject: [PATCH 1/3] =?UTF-8?q?ci:=20add=20PR=20hygiene=20=E2=80=94=20UNRE?= =?UTF-8?q?VIEWED=20label=20+=20Slack=20review=20reminders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New PRs get the UNREVIEWED label via a reusable workflow (consumers copy templates/pr-label.yml); reviewers remove it manually after reviewing. A weekday-morning cron DMs reviewers/authors on Slack about PRs still carrying the label (nag 1-3, then silent). Dry-run and allowlist phases via Actions variables; PAT now, GitHub App swap planned. Co-Authored-By: Claude Fable 5 --- .github/workflows/pr-label.yml | 13 ++ .github/workflows/pr-reminder.yml | 45 ++++++ .github/workflows/reusable-pr-label.yml | 32 ++++ docs/slack-app-manifest.yml | 22 +++ scripts/pr_reminder.py | 197 ++++++++++++++++++++++++ templates/pr-label.yml | 14 ++ 6 files changed, 323 insertions(+) create mode 100644 .github/workflows/pr-label.yml create mode 100644 .github/workflows/pr-reminder.yml create mode 100644 .github/workflows/reusable-pr-label.yml create mode 100644 docs/slack-app-manifest.yml create mode 100755 scripts/pr_reminder.py create mode 100644 templates/pr-label.yml 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..1cb8400 --- /dev/null +++ b/.github/workflows/pr-reminder.yml @@ -0,0 +1,45 @@ +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: + 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..1455664 --- /dev/null +++ b/scripts/pr_reminder.py @@ -0,0 +1,197 @@ +#!/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") + + +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) 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) 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 From 186e11eee33268dab408090865e6b1f845a0e83f Mon Sep 17 00:00:00 2001 From: Severin Demchuk Date: Mon, 13 Jul 2026 10:20:42 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20checkout=20permission=20and=20API=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pr-reminder.yml declared explicit permissions, which zeroes out any unlisted scope — actions/checkout needs contents: read to work. - pr_reminder.py: bound GitHub/Slack urlopen() calls with a timeout so a hung connection can't stall the whole workflow run. Co-Authored-By: Claude Fable 5 --- .github/workflows/pr-reminder.yml | 1 + scripts/pr_reminder.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-reminder.yml b/.github/workflows/pr-reminder.yml index 1cb8400..6230d54 100644 --- a/.github/workflows/pr-reminder.yml +++ b/.github/workflows/pr-reminder.yml @@ -10,6 +10,7 @@ on: workflow_dispatch: permissions: + contents: read # actions/checkout actions: write # keepalive step re-enables this workflow jobs: diff --git a/scripts/pr_reminder.py b/scripts/pr_reminder.py index 1455664..0d85275 100755 --- a/scripts/pr_reminder.py +++ b/scripts/pr_reminder.py @@ -33,6 +33,7 @@ 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): @@ -44,7 +45,7 @@ def github_request(path, token, params=None): "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }) - with urllib.request.urlopen(request) as response: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: return json.load(response) @@ -129,7 +130,7 @@ def send_dm(slack_id, text, token): "Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8", }) - with urllib.request.urlopen(request) as response: + 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')}") From 40af7fa684ce964a42a3686e8e86d74c29843fbf Mon Sep 17 00:00:00 2001 From: Severin Demchuk Date: Fri, 21 Aug 2026 14:26:17 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20human=20review=20=E2=80=94?= =?UTF-8?q?=20merged=20PRs,=20weekly=20cadence,=20assignee=20priority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laila's PR #12 review feedback: search query dropped is:open only, missing merged-but-unreviewed PRs (e.g. hotfixes reviewed after the fact) — now matches is:merged OR is:open. Cadence changed from daily/capped-at-3 to weekly/uncapped, since the goal is "don't forget entirely" not urgency escalation. pr_targets() now checks assignees first — reassigning a PR to the author after requesting changes should stop notifying the reviewer, since requested_reviewers isn't cleared by submitting a review. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/pr-reminder.yml | 8 +++--- README.md | 33 +++++++++++++++++++++++++ scripts/pr_reminder.py | 41 +++++++++++++++++-------------- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pr-reminder.yml b/.github/workflows/pr-reminder.yml index 6230d54..338050a 100644 --- a/.github/workflows/pr-reminder.yml +++ b/.github/workflows/pr-reminder.yml @@ -1,12 +1,12 @@ 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. +# Monday-morning cron: DMs assignees/reviewers/authors on Slack about open or +# merged 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 + - cron: '17 6 * * 1' # ~08:17 Zurich in summer, ~07:17 in winter workflow_dispatch: permissions: diff --git a/README.md b/README.md index c05a78b..2a96a8d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,39 @@ Repository with github actions for the CI workflow +## PR hygiene + +Automation so PRs don't sit unreviewed ([templates/pr-label.yml](templates/pr-label.yml), [.github/workflows](/.github/workflows), [scripts/pr_reminder.py](scripts/pr_reminder.py)). + +**How it works** + +1. **Label** — every new PR automatically gets the `UNREVIEWED` label (workflow in each repo, see below). Drafts and bot PRs included. +2. **Review, then remove the label manually** — removing `UNREVIEWED` is the "I reviewed this" acknowledgment. Automation never removes or re-adds it. +3. **Reminders** — a Monday-morning cron in this repo searches all open or merged PRs in the org that still carry the label (merged PRs still nag — e.g. a hotfix reviewed after the fact) and DMs the assignees on Slack, else the requested reviewers, else the author if no reviewer was requested. Weekly, no cap — this is a "don't forget it entirely" nudge, not an urgency escalation. + +**Adding the label workflow to a repo** + +Copy [templates/pr-label.yml](templates/pr-label.yml) to `.github/workflows/pr-label.yml`. That's all — no secrets needed. `scripts/rollout_pr_label.sh` opens these PRs org-wide (dry run by default, `--execute` to run). + +**Reminder configuration** (org/repo Actions variables + secrets on this repo) + +| Name | Kind | Purpose | +| --- | --- | --- | +| `GH_SLACK_MAP` | variable | JSON `{"github_login": "U_SLACK_MEMBER_ID"}`; unmapped users are skipped and logged | +| `PR_HYGIENE_DRY_RUN` | variable | anything but `false` = log instead of DM (safe default) | +| `PR_HYGIENE_ALLOWLIST` | variable | optional JSON array of GitHub logins; if non-empty only these get DMs (pilot) | +| `PR_BOT_PAT` | secret | GitHub token with org-wide PR read (test phase; swap for a GitHub App later, only the `GH_TOKEN` line in `pr-reminder.yml` changes) | +| `SLACK_BOT_TOKEN` | secret | bot token of the Slack app from [docs/slack-app-manifest.yml](docs/slack-app-manifest.yml) (scopes `chat:write`, `im:write`) | +| `SLACK_WEBHOOK` | secret | existing webhook, used only to report failed reminder runs | + +Find a Slack member ID: profile → ⋯ → "Copy member ID". + +**Launch phases:** 1) `PR_HYGIENE_DRY_RUN=true` — inspect run logs. 2) `false` + allowlist — pilot users get real DMs. 3) empty allowlist — org-wide. + +**GitHub App swap (production):** org owner creates an App (permissions: Pull requests read, Members read), installs it org-wide; add `APP_ID` var + `APP_PRIVATE_KEY` secret; in `pr-reminder.yml` mint the token with `actions/create-github-app-token@v2` and point `GH_TOKEN` at its output. + +Tests: `cd scripts && python3 -m unittest` + ## build ``` diff --git a/scripts/pr_reminder.py b/scripts/pr_reminder.py index 0d85275..8fdedff 100755 --- a/scripts/pr_reminder.py +++ b/scripts/pr_reminder.py @@ -1,13 +1,15 @@ #!/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. +Searches all open or merged PRs in the org labeled UNREVIEWED (merged PRs +still nag — e.g. a hotfix reviewed after the fact), and DMs the assignees if +any, else the requested reviewers, else the author, 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. +Cadence: weekly, Monday mornings, no cap — this is a "don't forget it +entirely" nudge, not an urgency escalation. Stateless — reminder text shows +how many weekdays the PR has been unreviewed, 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) @@ -31,7 +33,6 @@ 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 @@ -62,7 +63,7 @@ def github_paginate(path, token, params=None, items_key=None): def search_unreviewed_prs(org, token): - query = f"org:{org} is:pr is:open label:{LABEL}" + query = f"org:{org} is:pr (is:merged OR is:open) label:{LABEL}" return list(github_paginate("/search/issues", token, {"q": query}, items_key="items")) @@ -83,7 +84,7 @@ 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. + (weekends don't count). Used as the "N weekdays unreviewed" display age. """ count = 0 day = anchor_date @@ -95,11 +96,19 @@ def nag_number(anchor_date, today): def pr_targets(repo, number, author, token): - """(logins, is_author_fallback) — requested reviewers, else the author.""" + """(logins, is_author_fallback) — assignees, else requested reviewers, else the author. + + Assignees win first: reassigning a PR to someone (e.g. a reviewer handing + it back to the author after requesting changes) signals who the ball is + with now, and requested_reviewers isn't cleared by submitting a review. + """ 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)") + assignees = [user["login"] for user in pull.get("assignees", [])] + if assignees: + return assignees, False + reviewers = [user["login"] for user in pull.get("requested_reviewers", [])] if reviewers: return reviewers, False return [author], True @@ -112,9 +121,8 @@ def format_dm(entries): ] 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})") + lines.append(f"<{entry['url']}|{entry['title']}> ({entry['repo']}, {age})") if entry["author_fallback"]: lines.append(" ↳ your PR has *no reviewer assigned* — please request one") lines += [ @@ -145,7 +153,7 @@ def main(): 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})") + print(f"Found {len(prs)} open/merged PRs with label {LABEL} (dry_run={dry_run})") queue = {} # github login -> list of PR entries for pr in prs: @@ -157,10 +165,7 @@ def main(): 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") + print(f"SKIP {repo}#{number}: labeled today, first nag next Monday") continue targets, author_fallback = pr_targets(repo, number, pr["user"]["login"], gh_token) for login in targets: