From 071619f8883f08d91d4f8a590f7e5896a86586a4 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:02:27 +0100 Subject: [PATCH 01/14] script with gh actions to find busy reviewers --- .github/workflows/repo-updates.yml | 43 ++++ scripts/repo-updates-script.py | 318 +++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 .github/workflows/repo-updates.yml create mode 100644 scripts/repo-updates-script.py diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml new file mode 100644 index 000000000..433d9391c --- /dev/null +++ b/.github/workflows/repo-updates.yml @@ -0,0 +1,43 @@ +name: Reviewer load report + +on: + schedule: + # Runs daily at 08:00 UTC. Adjust to taste. + - cron: "0 8 * * *" + workflow_dispatch: {} + +permissions: + contents: read + pull-requests: read + +jobs: + report: + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Send reviewer report to Zulip + env: + # Default token works for reading PRs/review requests on this repo. + # If you want team-request expansion or collaborator listing on a + # repo where the default token lacks access, create a PAT with + # `repo` + `read:org` scopes, store it as the GH_TOKEN secret below, + # and it will be used instead. + GH_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + + WINDOW_DAYS: "30" + BUSY_THRESHOLD: "3" + # Optional: comma-separated usernames to force the roster instead + # of auto-deriving it. Uncomment and set as a repo variable if you + # want a fixed reviewer list. + # REVIEWERS_LIST: ${{ vars.REVIEWERS_LIST }} + + ZULIP_SITE: ${{ secrets.ZULIP_SITE }} + ZULIP_BOT_EMAIL: ${{ secrets.ZULIP_BOT_EMAIL }} + ZULIP_BOT_API_KEY: ${{ secrets.ZULIP_BOT_API_KEY }} + ZULIP_STREAM: ${{ secrets.ZULIP_STREAM }} + ZULIP_TOPIC: "Reviewer load report" + run: python3 scripts/reviewer_report.py diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py new file mode 100644 index 000000000..894a8f3d4 --- /dev/null +++ b/scripts/repo-updates-script.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Reviewer busy/quiet report. + +Looks at all open pull requests opened in the last N days on a GitHub repo, +counts how many currently have a pending (not-yet-completed) review request +against each reviewer, classifies reviewers as busy / moderate / quiet, and +posts a summary message to a Zulip stream. + +Configuration is entirely via environment variables (see README.md). +""" + +import os +import sys +import json +import datetime +import urllib.request +import urllib.error +import urllib.parse + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +GITHUB_API = "https://api.github.com" + +GITHUB_TOKEN = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") +REPO = os.environ["GITHUB_REPOSITORY"] # "owner/repo", set automatically in Actions +OWNER, REPO_NAME = REPO.split("/") + +WINDOW_DAYS = int(os.environ.get("WINDOW_DAYS", "30")) +BUSY_THRESHOLD = int(os.environ.get("BUSY_THRESHOLD", "3")) +MAX_PRS_LISTED = int(os.environ.get("MAX_PRS_LISTED", "3")) + +# Optional explicit roster override, comma-separated GitHub usernames. +# If unset, the script derives the roster from everyone who currently +# appears as a requested reviewer on a qualifying PR, plus (if reachable) +# the repo's collaborator list. +REVIEWERS_LIST = os.environ.get("REVIEWERS_LIST", "") + +ZULIP_SITE = os.environ["ZULIP_SITE"].rstrip("/") +ZULIP_EMAIL = os.environ["ZULIP_BOT_EMAIL"] +ZULIP_API_KEY = os.environ["ZULIP_BOT_API_KEY"] +ZULIP_STREAM = os.environ["ZULIP_STREAM"] +ZULIP_TOPIC = os.environ.get("ZULIP_TOPIC", "Reviewer load report") + + +# --------------------------------------------------------------------------- +# GitHub helpers +# --------------------------------------------------------------------------- + +def gh_request(path, params=None): + url = f"{GITHUB_API}{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github+json") + req.add_header("X-GitHub-Api-Version", "2022-11-28") + if GITHUB_TOKEN: + req.add_header("Authorization", f"Bearer {GITHUB_TOKEN}") + try: + with urllib.request.urlopen(req) as resp: + body = resp.read() + link_header = resp.headers.get("Link", "") + return json.loads(body), link_header + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + print(f"GitHub API error for {url}: {e.code} {detail}", file=sys.stderr) + raise + + +def gh_paginate(path, params=None): + params = dict(params or {}) + params.setdefault("per_page", 100) + page = 1 + results = [] + while True: + params["page"] = page + data, link_header = gh_request(path, params) + if not data: + break + results.extend(data) + if 'rel="next"' not in link_header: + break + page += 1 + return results + + +def fetch_open_prs_in_window(cutoff): + prs = gh_paginate( + f"/repos/{OWNER}/{REPO_NAME}/pulls", + {"state": "open", "sort": "created", "direction": "desc"}, + ) + qualifying = [] + for pr in prs: + created_at = datetime.datetime.strptime( + pr["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=datetime.timezone.utc) + if created_at >= cutoff: + qualifying.append(pr) + else: + # PRs are sorted newest-first, so once we're past the window + # everything after is also out of range. + break + return qualifying + + +def expand_team_members(team_slug): + """Best-effort: only works if the token has org read access.""" + try: + members = gh_paginate(f"/orgs/{OWNER}/teams/{team_slug}/members") + return [m["login"] for m in members] + except Exception: + print( + f"Warning: could not expand team '{team_slug}' " + f"(likely needs a token with read:org scope). Skipping.", + file=sys.stderr, + ) + return [] + + +def fetch_recently_merged_prs(): + """Return PRs whose merged_at timestamp falls in the previous UTC calendar day.""" + now = datetime.datetime.now(datetime.timezone.utc) + yesterday_start = (now - datetime.timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + yesterday_end = yesterday_start.replace(hour=23, minute=59, second=59) + + prs = gh_paginate( + f"/repos/{OWNER}/{REPO_NAME}/pulls", + {"state": "closed", "sort": "updated", "direction": "desc"}, + ) + merged = [] + for pr in prs: + if not pr.get("merged_at"): + continue + merged_at = datetime.datetime.strptime( + pr["merged_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=datetime.timezone.utc) + if merged_at < yesterday_start: + # sorted newest-first by updated_at; once merged_at is before + # yesterday we can't guarantee order, so keep scanning briefly + # but stop after 200 non-matching closed PRs to avoid huge pages. + break + if yesterday_start <= merged_at <= yesterday_end: + merged.append(pr) + return merged + + +def fetch_collaborators(): + try: + collabs = gh_paginate( + f"/repos/{OWNER}/{REPO_NAME}/collaborators", {"affiliation": "all"} + ) + return [c["login"] for c in collabs] + except Exception: + print( + "Warning: could not list collaborators (needs push access on the " + "repo). Roster will be built from requested reviewers only.", + file=sys.stderr, + ) + return [] + + +# --------------------------------------------------------------------------- +# Core logic +# --------------------------------------------------------------------------- + +def build_report(): + now = datetime.datetime.now(datetime.timezone.utc) + cutoff = now - datetime.timedelta(days=WINDOW_DAYS) + + prs = fetch_open_prs_in_window(cutoff) + + pending_counts = {} + pending_prs = {} # reviewer -> list of (number, title, url) + unreviewed_prs = [] # open PRs with no reviewer assigned + + for pr in prs: + reviewers = [r["login"] for r in pr.get("requested_reviewers", [])] + for team in pr.get("requested_teams", []): + reviewers.extend(expand_team_members(team["slug"])) + + if not reviewers: + unreviewed_prs.append((pr["number"], pr["title"], pr["html_url"])) + + for login in reviewers: + pending_counts[login] = pending_counts.get(login, 0) + 1 + pending_prs.setdefault(login, []).append( + (pr["number"], pr["title"], pr["html_url"]) + ) + + merged_yesterday = fetch_recently_merged_prs() + + # Build roster: explicit override > collaborators ∪ requested reviewers + if REVIEWERS_LIST.strip(): + roster = [r.strip() for r in REVIEWERS_LIST.split(",") if r.strip()] + else: + roster = set(fetch_collaborators()) | set(pending_counts.keys()) + roster = sorted(roster) + + busy, moderate, quiet = [], [], [] + for login in roster: + count = pending_counts.get(login, 0) + if count >= BUSY_THRESHOLD: + busy.append((login, count)) + elif count == 0: + quiet.append((login, count)) + else: + moderate.append((login, count)) + + busy.sort(key=lambda x: -x[1]) + moderate.sort(key=lambda x: -x[1]) + quiet.sort(key=lambda x: x[0].lower()) + + return { + "window_days": WINDOW_DAYS, + "busy_threshold": BUSY_THRESHOLD, + "pr_count": len(prs), + "busy": busy, + "moderate": moderate, + "quiet": quiet, + "pending_prs": pending_prs, + "unreviewed_prs": unreviewed_prs, + "merged_yesterday": [ + (pr["number"], pr["title"], pr["html_url"], pr["user"]["login"]) + for pr in merged_yesterday + ], + } + + +def format_message(report): + lines = [] + lines.append( + f"**Reviewer load report** — {REPO} " + f"(open PRs from the last {report['window_days']} days, " + f"{report['pr_count']} PR(s) considered)" + ) + lines.append("") + + def section(title, entries, show_prs=False): + lines.append(f"**{title}** ({len(entries)})") + if not entries: + lines.append("- _none_") + for login, count in entries: + suffix = f" — {count} pending review(s)" if count else "" + lines.append(f"- @**{login}**{suffix}") + if show_prs: + for number, title_, url in report["pending_prs"].get(login, [])[ + :MAX_PRS_LISTED + ]: + lines.append(f" - [#{number} {title_}]({url})") + lines.append("") + + # --- unreviewed open PRs --- + unreviewed = report["unreviewed_prs"] + lines.append(f"**⚪ Open PRs with no reviewer assigned** ({len(unreviewed)})") + if not unreviewed: + lines.append("- _none_") + for number, title_, url in unreviewed: + lines.append(f"- [#{number} {title_}]({url})") + lines.append("") + + section( + f"🔴 Busy (≥{report['busy_threshold']} pending reviews)", + report["busy"], + show_prs=True, + ) + section("🟡 Moderate", report["moderate"]) + section("🟢 Quiet (0 pending reviews)", report["quiet"]) + + # --- merged yesterday --- + merged = report["merged_yesterday"] + lines.append(f"**✅ Merged yesterday** ({len(merged)})") + if not merged: + lines.append("- _none_") + for number, title_, url, author in merged: + lines.append(f"- [#{number} {title_}]({url}) by @**{author}**") + lines.append("") + + return "\n".join(lines) + + +def post_to_zulip(content): + data = urllib.parse.urlencode( + { + "type": "stream", + "to": ZULIP_STREAM, + "topic": ZULIP_TOPIC, + "content": content, + } + ).encode() + + req = urllib.request.Request(f"{ZULIP_SITE}/api/v1/messages", data=data) + credentials = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}" + import base64 + + req.add_header( + "Authorization", "Basic " + base64.b64encode(credentials.encode()).decode() + ) + try: + with urllib.request.urlopen(req) as resp: + print("Zulip response:", resp.read().decode()) + except urllib.error.HTTPError as e: + print("Zulip API error:", e.code, e.read().decode(), file=sys.stderr) + raise + + +def main(): + report = build_report() + message = format_message(report) + print(message) + post_to_zulip(message) + + +if __name__ == "__main__": + main() From fa6364abc13dd00e0116d6af6de79a05fa8ee53e Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:34:26 +0100 Subject: [PATCH 02/14] fix typo --- .github/workflows/repo-updates.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml index 433d9391c..96b23e939 100644 --- a/.github/workflows/repo-updates.yml +++ b/.github/workflows/repo-updates.yml @@ -40,4 +40,4 @@ jobs: ZULIP_BOT_API_KEY: ${{ secrets.ZULIP_BOT_API_KEY }} ZULIP_STREAM: ${{ secrets.ZULIP_STREAM }} ZULIP_TOPIC: "Reviewer load report" - run: python3 scripts/reviewer_report.py + run: python3 scripts/repo-updates-script.py From 2ae17c867aa3db3a717524fcd5aef65c46ededc9 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:05:16 +0100 Subject: [PATCH 03/14] updated comments and check all PRs (not just last 30 days) --- .github/workflows/repo-updates.yml | 11 +++++----- scripts/repo-updates-script.py | 35 ++++++++++-------------------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml index 96b23e939..f85663ee1 100644 --- a/.github/workflows/repo-updates.yml +++ b/.github/workflows/repo-updates.yml @@ -2,7 +2,7 @@ name: Reviewer load report on: schedule: - # Runs daily at 08:00 UTC. Adjust to taste. + # Runs daily at this time - cron: "0 8 * * *" workflow_dispatch: {} @@ -19,15 +19,12 @@ jobs: - name: Send reviewer report to Zulip env: - # Default token works for reading PRs/review requests on this repo. - # If you want team-request expansion or collaborator listing on a - # repo where the default token lacks access, create a PAT with - # `repo` + `read:org` scopes, store it as the GH_TOKEN secret below, - # and it will be used instead. + # These GitHub tokens are already configured GH_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} + # Number of days in the past to check WINDOW_DAYS: "30" BUSY_THRESHOLD: "3" # Optional: comma-separated usernames to force the roster instead @@ -35,6 +32,8 @@ jobs: # want a fixed reviewer list. # REVIEWERS_LIST: ${{ vars.REVIEWERS_LIST }} + # These Zulip secrets are configured in the repo settings + # (Physlib -> Settings -> Secrets and variables -> Actions) ZULIP_SITE: ${{ secrets.ZULIP_SITE }} ZULIP_BOT_EMAIL: ${{ secrets.ZULIP_BOT_EMAIL }} ZULIP_BOT_API_KEY: ${{ secrets.ZULIP_BOT_API_KEY }} diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 894a8f3d4..1260705bc 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -28,7 +28,6 @@ REPO = os.environ["GITHUB_REPOSITORY"] # "owner/repo", set automatically in Actions OWNER, REPO_NAME = REPO.split("/") -WINDOW_DAYS = int(os.environ.get("WINDOW_DAYS", "30")) BUSY_THRESHOLD = int(os.environ.get("BUSY_THRESHOLD", "3")) MAX_PRS_LISTED = int(os.environ.get("MAX_PRS_LISTED", "3")) @@ -86,7 +85,7 @@ def gh_paginate(path, params=None): return results -def fetch_open_prs_in_window(cutoff): +def fetch_open_prs_in_window(): prs = gh_paginate( f"/repos/{OWNER}/{REPO_NAME}/pulls", {"state": "open", "sort": "created", "direction": "desc"}, @@ -96,12 +95,8 @@ def fetch_open_prs_in_window(cutoff): created_at = datetime.datetime.strptime( pr["created_at"], "%Y-%m-%dT%H:%M:%SZ" ).replace(tzinfo=datetime.timezone.utc) - if created_at >= cutoff: - qualifying.append(pr) - else: - # PRs are sorted newest-first, so once we're past the window - # everything after is also out of range. - break + qualifying.append(pr) + return qualifying @@ -162,16 +157,10 @@ def fetch_collaborators(): ) return [] - -# --------------------------------------------------------------------------- -# Core logic -# --------------------------------------------------------------------------- - +# get all the important values needed for the message def build_report(): now = datetime.datetime.now(datetime.timezone.utc) - cutoff = now - datetime.timedelta(days=WINDOW_DAYS) - - prs = fetch_open_prs_in_window(cutoff) + prs = fetch_open_prs_in_window pending_counts = {} pending_prs = {} # reviewer -> list of (number, title, url) @@ -193,7 +182,7 @@ def build_report(): merged_yesterday = fetch_recently_merged_prs() - # Build roster: explicit override > collaborators ∪ requested reviewers + if REVIEWERS_LIST.strip(): roster = [r.strip() for r in REVIEWERS_LIST.split(",") if r.strip()] else: @@ -215,7 +204,6 @@ def build_report(): quiet.sort(key=lambda x: x[0].lower()) return { - "window_days": WINDOW_DAYS, "busy_threshold": BUSY_THRESHOLD, "pr_count": len(prs), "busy": busy, @@ -230,12 +218,11 @@ def build_report(): } +# make the message string def format_message(report): lines = [] lines.append( - f"**Reviewer load report** — {REPO} " - f"(open PRs from the last {report['window_days']} days, " - f"{report['pr_count']} PR(s) considered)" + "Summary of PRs that need attention and available reviewers" ) lines.append("") @@ -253,7 +240,7 @@ def section(title, entries, show_prs=False): lines.append(f" - [#{number} {title_}]({url})") lines.append("") - # --- unreviewed open PRs --- + # unreviewed open PRs unreviewed = report["unreviewed_prs"] lines.append(f"**⚪ Open PRs with no reviewer assigned** ({len(unreviewed)})") if not unreviewed: @@ -270,7 +257,7 @@ def section(title, entries, show_prs=False): section("🟡 Moderate", report["moderate"]) section("🟢 Quiet (0 pending reviews)", report["quiet"]) - # --- merged yesterday --- + # PRs merged yesterday merged = report["merged_yesterday"] lines.append(f"**✅ Merged yesterday** ({len(merged)})") if not merged: @@ -281,7 +268,7 @@ def section(title, entries, show_prs=False): return "\n".join(lines) - +# use the Zulip bot to post to the thread def post_to_zulip(content): data = urllib.parse.urlencode( { From 9f20a75203a96f85346f9f802a10650afecf90a3 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:40:03 +0100 Subject: [PATCH 04/14] dm --- scripts/repo-updates-script.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 1260705bc..9fc1817cd 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -173,7 +173,6 @@ def build_report(): if not reviewers: unreviewed_prs.append((pr["number"], pr["title"], pr["html_url"])) - for login in reviewers: pending_counts[login] = pending_counts.get(login, 0) + 1 pending_prs.setdefault(login, []).append( @@ -230,14 +229,15 @@ def section(title, entries, show_prs=False): lines.append(f"**{title}** ({len(entries)})") if not entries: lines.append("- _none_") - for login, count in entries: - suffix = f" — {count} pending review(s)" if count else "" - lines.append(f"- @**{login}**{suffix}") - if show_prs: - for number, title_, url in report["pending_prs"].get(login, [])[ - :MAX_PRS_LISTED - ]: - lines.append(f" - [#{number} {title_}]({url})") + else: + for login, count in entries: + suffix = f" — {count} pending review(s)" if count else "" + lines.append(f"- @**{login}**{suffix}") + if show_prs: + for number, title_, url in report["pending_prs"].get(login, [])[ + :MAX_PRS_LISTED + ]: + lines.append(f" - [#{number} {title_}]({url})") lines.append("") # unreviewed open PRs @@ -268,16 +268,24 @@ def section(title, entries, show_prs=False): return "\n".join(lines) -# use the Zulip bot to post to the thread + def post_to_zulip(content): - data = urllib.parse.urlencode( + # uncomment this to post to thread + ''' data = urllib.parse.urlencode( { "type": "stream", "to": ZULIP_STREAM, "topic": ZULIP_TOPIC, "content": content, } - ).encode() + ).encode()''' + data = urllib.parse.urlencode( + { + "type": "private", + "to": json.dumps(['alexander@zughaid.co.uk']), + "content": content, + } + ) req = urllib.request.Request(f"{ZULIP_SITE}/api/v1/messages", data=data) credentials = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}" From 7e775c2fb93bc526414f43ba76fe5b8a53205145 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:55:46 +0100 Subject: [PATCH 05/14] fix indent error --- scripts/repo-updates-script.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 9fc1817cd..402707f29 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -270,22 +270,23 @@ def section(title, entries, show_prs=False): def post_to_zulip(content): - # uncomment this to post to thread - ''' data = urllib.parse.urlencode( + + # uncomment this to post to thread + #data = urllib.parse.urlencode( + # { + # "type": "stream", + # "to": ZULIP_STREAM, + # "topic": ZULIP_TOPIC, + # "content": content, + # } + #).encode() + data = urllib.parse.urlencode( { - "type": "stream", - "to": ZULIP_STREAM, - "topic": ZULIP_TOPIC, - "content": content, - } - ).encode()''' - data = urllib.parse.urlencode( - { - "type": "private", + "type": "private", "to": json.dumps(['alexander@zughaid.co.uk']), "content": content, - } - ) + } + ).encode() req = urllib.request.Request(f"{ZULIP_SITE}/api/v1/messages", data=data) credentials = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}" From 35e133b36dc77fd8597662bef9704c1dca8b3320 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:00:11 +0100 Subject: [PATCH 06/14] fix function error --- scripts/repo-updates-script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 402707f29..778039703 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -160,7 +160,7 @@ def fetch_collaborators(): # get all the important values needed for the message def build_report(): now = datetime.datetime.now(datetime.timezone.utc) - prs = fetch_open_prs_in_window + prs = fetch_open_prs_in_window() pending_counts = {} pending_prs = {} # reviewer -> list of (number, title, url) From 6981ba30f224e00461f1718973d0abb75a100655 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:14:16 +0100 Subject: [PATCH 07/14] get rid of exception error --- scripts/repo-updates-script.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 778039703..00160ee9e 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -300,7 +300,6 @@ def post_to_zulip(content): print("Zulip response:", resp.read().decode()) except urllib.error.HTTPError as e: print("Zulip API error:", e.code, e.read().decode(), file=sys.stderr) - raise def main(): From 3ddf58a311ff0e1ab5bf40247db9dbe9aa5386d8 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:23:38 +0100 Subject: [PATCH 08/14] userID instead of email --- scripts/repo-updates-script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 00160ee9e..0ba60aba2 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -283,7 +283,7 @@ def post_to_zulip(content): data = urllib.parse.urlencode( { "type": "private", - "to": json.dumps(['alexander@zughaid.co.uk']), + "to": json.dumps([1175816]), "content": content, } ).encode() From 8363061235d5533f3c840163bbf57353fac48209 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:44:37 +0100 Subject: [PATCH 09/14] line numbers and labels --- scripts/repo-updates-script.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 0ba60aba2..6120c6cae 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -100,6 +100,11 @@ def fetch_open_prs_in_window(): return qualifying +def fetch_pr_lines_changed(number): + data, _ = gh_request(f"/repos/{OWNER}/{REPO_NAME}/pulls/{number}") + return data.get("additions", 0) + data.get("deletions", 0) + + def expand_team_members(team_slug): """Best-effort: only works if the token has org read access.""" try: @@ -172,7 +177,9 @@ def build_report(): reviewers.extend(expand_team_members(team["slug"])) if not reviewers: - unreviewed_prs.append((pr["number"], pr["title"], pr["html_url"])) + labels = [lbl["name"] for lbl in pr.get("labels", [])] + lines_changed = fetch_pr_lines_changed(pr["number"]) + unreviewed_prs.append((pr["number"], pr["title"], pr["html_url"], labels, lines_changed)) for login in reviewers: pending_counts[login] = pending_counts.get(login, 0) + 1 pending_prs.setdefault(login, []).append( @@ -245,8 +252,9 @@ def section(title, entries, show_prs=False): lines.append(f"**⚪ Open PRs with no reviewer assigned** ({len(unreviewed)})") if not unreviewed: lines.append("- _none_") - for number, title_, url in unreviewed: - lines.append(f"- [#{number} {title_}]({url})") + for number, title_, url, labels, lines_changed in unreviewed: + tag_str = " " + " ".join(f"`{l}`" for l in labels) if labels else "" + lines.append(f"- [#{number} {title_}]({url}){tag_str} — {lines_changed} lines changed") lines.append("") section( From a65e18022fe1d4c19bca1b55bccdcd84826ee9e8 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:01:30 +0100 Subject: [PATCH 10/14] tidy up file --- .github/workflows/repo-updates.yml | 4 -- scripts/repo-updates-script.py | 90 ++++++++---------------------- 2 files changed, 22 insertions(+), 72 deletions(-) diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml index f85663ee1..caa40218e 100644 --- a/.github/workflows/repo-updates.yml +++ b/.github/workflows/repo-updates.yml @@ -27,10 +27,6 @@ jobs: # Number of days in the past to check WINDOW_DAYS: "30" BUSY_THRESHOLD: "3" - # Optional: comma-separated usernames to force the roster instead - # of auto-deriving it. Uncomment and set as a repo variable if you - # want a fixed reviewer list. - # REVIEWERS_LIST: ${{ vars.REVIEWERS_LIST }} # These Zulip secrets are configured in the repo settings # (Physlib -> Settings -> Secrets and variables -> Actions) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 6120c6cae..780b72288 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -1,15 +1,3 @@ -#!/usr/bin/env python3 -""" -Reviewer busy/quiet report. - -Looks at all open pull requests opened in the last N days on a GitHub repo, -counts how many currently have a pending (not-yet-completed) review request -against each reviewer, classifies reviewers as busy / moderate / quiet, and -posts a summary message to a Zulip stream. - -Configuration is entirely via environment variables (see README.md). -""" - import os import sys import json @@ -18,10 +6,7 @@ import urllib.error import urllib.parse -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - +# Constants GITHUB_API = "https://api.github.com" GITHUB_TOKEN = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") @@ -31,12 +16,6 @@ BUSY_THRESHOLD = int(os.environ.get("BUSY_THRESHOLD", "3")) MAX_PRS_LISTED = int(os.environ.get("MAX_PRS_LISTED", "3")) -# Optional explicit roster override, comma-separated GitHub usernames. -# If unset, the script derives the roster from everyone who currently -# appears as a requested reviewer on a qualifying PR, plus (if reachable) -# the repo's collaborator list. -REVIEWERS_LIST = os.environ.get("REVIEWERS_LIST", "") - ZULIP_SITE = os.environ["ZULIP_SITE"].rstrip("/") ZULIP_EMAIL = os.environ["ZULIP_BOT_EMAIL"] ZULIP_API_KEY = os.environ["ZULIP_BOT_API_KEY"] @@ -44,10 +23,6 @@ ZULIP_TOPIC = os.environ.get("ZULIP_TOPIC", "Reviewer load report") -# --------------------------------------------------------------------------- -# GitHub helpers -# --------------------------------------------------------------------------- - def gh_request(path, params=None): url = f"{GITHUB_API}{path}" if params: @@ -68,6 +43,7 @@ def gh_request(path, params=None): raise +# this function makes the results of multiple github pages into a single list def gh_paginate(path, params=None): params = dict(params or {}) params.setdefault("per_page", 100) @@ -84,41 +60,24 @@ def gh_paginate(path, params=None): page += 1 return results - +# get a list of open PRs def fetch_open_prs_in_window(): prs = gh_paginate( f"/repos/{OWNER}/{REPO_NAME}/pulls", {"state": "open", "sort": "created", "direction": "desc"}, ) - qualifying = [] + result = [] for pr in prs: - created_at = datetime.datetime.strptime( - pr["created_at"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=datetime.timezone.utc) - qualifying.append(pr) - - return qualifying + result.append(pr) + return result +# get the number of lines changed in the PR to estimate the size def fetch_pr_lines_changed(number): data, _ = gh_request(f"/repos/{OWNER}/{REPO_NAME}/pulls/{number}") return data.get("additions", 0) + data.get("deletions", 0) -def expand_team_members(team_slug): - """Best-effort: only works if the token has org read access.""" - try: - members = gh_paginate(f"/orgs/{OWNER}/teams/{team_slug}/members") - return [m["login"] for m in members] - except Exception: - print( - f"Warning: could not expand team '{team_slug}' " - f"(likely needs a token with read:org scope). Skipping.", - file=sys.stderr, - ) - return [] - - def fetch_recently_merged_prs(): """Return PRs whose merged_at timestamp falls in the previous UTC calendar day.""" now = datetime.datetime.now(datetime.timezone.utc) @@ -147,7 +106,8 @@ def fetch_recently_merged_prs(): merged.append(pr) return merged - +# gets people who arent assigned as a reviewer, but have pushed to the repo in the past +# needed to find people currently assigned to 0 reviews def fetch_collaborators(): try: collabs = gh_paginate( @@ -164,7 +124,6 @@ def fetch_collaborators(): # get all the important values needed for the message def build_report(): - now = datetime.datetime.now(datetime.timezone.utc) prs = fetch_open_prs_in_window() pending_counts = {} @@ -173,8 +132,6 @@ def build_report(): for pr in prs: reviewers = [r["login"] for r in pr.get("requested_reviewers", [])] - for team in pr.get("requested_teams", []): - reviewers.extend(expand_team_members(team["slug"])) if not reviewers: labels = [lbl["name"] for lbl in pr.get("labels", [])] @@ -189,12 +146,10 @@ def build_report(): merged_yesterday = fetch_recently_merged_prs() - if REVIEWERS_LIST.strip(): - roster = [r.strip() for r in REVIEWERS_LIST.split(",") if r.strip()] - else: - roster = set(fetch_collaborators()) | set(pending_counts.keys()) - roster = sorted(roster) + roster = set(fetch_collaborators()) | set(pending_counts.keys()) + roster = sorted(roster) + # sort into 3 groups busy, moderate, quiet = [], [], [] for login in roster: count = pending_counts.get(login, 0) @@ -278,23 +233,22 @@ def section(title, entries, show_prs=False): def post_to_zulip(content): - - # uncomment this to post to thread - #data = urllib.parse.urlencode( - # { - # "type": "stream", - # "to": ZULIP_STREAM, - # "topic": ZULIP_TOPIC, - # "content": content, - # } - #).encode() data = urllib.parse.urlencode( + { + "type": "stream", + "to": ZULIP_STREAM, + "topic": ZULIP_TOPIC, + "content": content, + } + ).encode() + # send a DM (Testing only) + '''data = urllib.parse.urlencode( { "type": "private", "to": json.dumps([1175816]), "content": content, } - ).encode() + ).encode()''' req = urllib.request.Request(f"{ZULIP_SITE}/api/v1/messages", data=data) credentials = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}" From 26498e0210edefac2e6521ddcbbb67d481cce59e Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:14:17 +0100 Subject: [PATCH 11/14] add recently opened PRs to the summary --- scripts/repo-updates-script.py | 59 +++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 780b72288..3f562e1c8 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -81,10 +81,7 @@ def fetch_pr_lines_changed(number): def fetch_recently_merged_prs(): """Return PRs whose merged_at timestamp falls in the previous UTC calendar day.""" now = datetime.datetime.now(datetime.timezone.utc) - yesterday_start = (now - datetime.timedelta(days=1)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - yesterday_end = yesterday_start.replace(hour=23, minute=59, second=59) + cutoff = now - datetime.timedelta(hours=24) prs = gh_paginate( f"/repos/{OWNER}/{REPO_NAME}/pulls", @@ -97,15 +94,31 @@ def fetch_recently_merged_prs(): merged_at = datetime.datetime.strptime( pr["merged_at"], "%Y-%m-%dT%H:%M:%SZ" ).replace(tzinfo=datetime.timezone.utc) - if merged_at < yesterday_start: - # sorted newest-first by updated_at; once merged_at is before - # yesterday we can't guarantee order, so keep scanning briefly - # but stop after 200 non-matching closed PRs to avoid huge pages. + if merged_at < cutoff: break - if yesterday_start <= merged_at <= yesterday_end: - merged.append(pr) + merged.append(pr) return merged + +def fetch_recently_opened_prs(): + """Return PRs created in the last 24 hours.""" + now = datetime.datetime.now(datetime.timezone.utc) + cutoff = now - datetime.timedelta(hours=24) + + prs = gh_paginate( + f"/repos/{OWNER}/{REPO_NAME}/pulls", + {"state": "all", "sort": "created", "direction": "desc"}, + ) + opened = [] + for pr in prs: + created_at = datetime.datetime.strptime( + pr["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=datetime.timezone.utc) + if created_at < cutoff: + break + opened.append(pr) + return opened + # gets people who arent assigned as a reviewer, but have pushed to the repo in the past # needed to find people currently assigned to 0 reviews def fetch_collaborators(): @@ -143,7 +156,8 @@ def build_report(): (pr["number"], pr["title"], pr["html_url"]) ) - merged_yesterday = fetch_recently_merged_prs() + merged_recently = fetch_recently_merged_prs() + opened_recently = fetch_recently_opened_prs() roster = set(fetch_collaborators()) | set(pending_counts.keys()) @@ -172,9 +186,13 @@ def build_report(): "quiet": quiet, "pending_prs": pending_prs, "unreviewed_prs": unreviewed_prs, - "merged_yesterday": [ + "merged_recently": [ + (pr["number"], pr["title"], pr["html_url"], pr["user"]["login"]) + for pr in merged_recently + ], + "opened_recently": [ (pr["number"], pr["title"], pr["html_url"], pr["user"]["login"]) - for pr in merged_yesterday + for pr in opened_recently ], } @@ -220,9 +238,18 @@ def section(title, entries, show_prs=False): section("🟡 Moderate", report["moderate"]) section("🟢 Quiet (0 pending reviews)", report["quiet"]) - # PRs merged yesterday - merged = report["merged_yesterday"] - lines.append(f"**✅ Merged yesterday** ({len(merged)})") + # PRs opened in the last 24h + opened = report["opened_recently"] + lines.append(f"**🟤 Opened in the last 24h** ({len(opened)})") + if not opened: + lines.append("- _none_") + for number, title_, url, author in opened: + lines.append(f"- [#{number} {title_}]({url}) by @**{author}**") + lines.append("") + + # PRs merged in the last 24h + merged = report["merged_recently"] + lines.append(f"**✅ Merged in the last 24h** ({len(merged)})") if not merged: lines.append("- _none_") for number, title_, url, author in merged: From 88d3ac740bb3ede0f464681b422117730d2baee6 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:16:58 +0100 Subject: [PATCH 12/14] repo updates --- .github/workflows/repo-updates.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml index caa40218e..9028564c6 100644 --- a/.github/workflows/repo-updates.yml +++ b/.github/workflows/repo-updates.yml @@ -1,4 +1,4 @@ -name: Reviewer load report +name: Repo Updates on: schedule: @@ -34,5 +34,5 @@ jobs: ZULIP_BOT_EMAIL: ${{ secrets.ZULIP_BOT_EMAIL }} ZULIP_BOT_API_KEY: ${{ secrets.ZULIP_BOT_API_KEY }} ZULIP_STREAM: ${{ secrets.ZULIP_STREAM }} - ZULIP_TOPIC: "Reviewer load report" + ZULIP_TOPIC: "Repo Updates" run: python3 scripts/repo-updates-script.py From 9ef8d1dcf23262c38ef0de72a32b9fb356413d8d Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:08:58 +0100 Subject: [PATCH 13/14] implemented a few claude comments --- .github/workflows/repo-updates.yml | 3 +-- scripts/repo-updates-script.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml index 9028564c6..491a0a241 100644 --- a/.github/workflows/repo-updates.yml +++ b/.github/workflows/repo-updates.yml @@ -24,8 +24,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - # Number of days in the past to check - WINDOW_DAYS: "30" + # Number of reviews to consider 'busy' BUSY_THRESHOLD: "3" # These Zulip secrets are configured in the repo settings diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py index 3f562e1c8..e981cd77b 100644 --- a/scripts/repo-updates-script.py +++ b/scripts/repo-updates-script.py @@ -5,6 +5,7 @@ import urllib.request import urllib.error import urllib.parse +import base64 # Constants GITHUB_API = "https://api.github.com" @@ -95,7 +96,7 @@ def fetch_recently_merged_prs(): pr["merged_at"], "%Y-%m-%dT%H:%M:%SZ" ).replace(tzinfo=datetime.timezone.utc) if merged_at < cutoff: - break + continue merged.append(pr) return merged @@ -279,7 +280,6 @@ def post_to_zulip(content): req = urllib.request.Request(f"{ZULIP_SITE}/api/v1/messages", data=data) credentials = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}" - import base64 req.add_header( "Authorization", "Basic " + base64.b64encode(credentials.encode()).decode() From 330d8c9c565948be113088e59ee03b62b5138913 Mon Sep 17 00:00:00 2001 From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:27:06 +0100 Subject: [PATCH 14/14] Switch Repo Updates trigger to Zulip DM via repository_dispatch Adds the Cloudflare Worker relay that bridges a Zulip outgoing webhook (bot receives a DM) to GitHub's repository_dispatch API, replacing the daily 8am cron trigger. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/repo-updates.yml | 5 +-- zulip-dm-relay/.gitignore | 2 + zulip-dm-relay/README.md | 33 ++++++++++++++++ zulip-dm-relay/src/index.js | 63 ++++++++++++++++++++++++++++++ zulip-dm-relay/wrangler.toml | 11 ++++++ 5 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 zulip-dm-relay/.gitignore create mode 100644 zulip-dm-relay/README.md create mode 100644 zulip-dm-relay/src/index.js create mode 100644 zulip-dm-relay/wrangler.toml diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml index 491a0a241..50c7a69f4 100644 --- a/.github/workflows/repo-updates.yml +++ b/.github/workflows/repo-updates.yml @@ -1,9 +1,8 @@ name: Repo Updates on: - schedule: - # Runs daily at this time - - cron: "0 8 * * *" + repository_dispatch: + types: [zulip-dm] workflow_dispatch: {} permissions: diff --git a/zulip-dm-relay/.gitignore b/zulip-dm-relay/.gitignore new file mode 100644 index 000000000..41a257894 --- /dev/null +++ b/zulip-dm-relay/.gitignore @@ -0,0 +1,2 @@ +.wrangler/ +node_modules/ diff --git a/zulip-dm-relay/README.md b/zulip-dm-relay/README.md new file mode 100644 index 000000000..dbbc970c7 --- /dev/null +++ b/zulip-dm-relay/README.md @@ -0,0 +1,33 @@ +# Zulip DM relay + +Cloudflare Worker that bridges Zulip to GitHub Actions: when the configured +Zulip bot receives a direct message, this relay fires a `repository_dispatch` +event (`zulip-dm`) against this repo, which the +[Repo Updates workflow](../.github/workflows/repo-updates.yml) listens for. + +GitHub Actions has no native "Zulip DM" trigger, and Zulip's outgoing webhook +can't call the GitHub API directly (it can't attach the auth token GitHub +requires), so this small service sits in between. + +## Deploy + +1. Create a free Cloudflare account at https://dash.cloudflare.com/sign-up. +2. Create a GitHub fine-grained personal access token scoped to this repo + only, with **Contents: Read and write** permission (required for + `repository_dispatch`). +3. From this directory: + ```bash + npx wrangler login + npx wrangler secret put GITHUB_TOKEN # paste the token from step 2 + npx wrangler secret put ZULIP_WEBHOOK_TOKEN # any random string, e.g. `openssl rand -hex 20` + npx wrangler deploy + ``` + This prints a `*.workers.dev` URL. +4. In Zulip, create an **Outgoing webhook** bot (Settings -> Personal + settings -> Bots -> Add a new bot), interface "Generic", endpoint URL = + the `workers.dev` URL from step 3, and set the bot's token to the same + string used for `ZULIP_WEBHOOK_TOKEN` above. +5. DM the bot. It should trigger a "Repo Updates" run in the Actions tab + within a few seconds. + +Debug with `npx wrangler tail` while sending a test DM. diff --git a/zulip-dm-relay/src/index.js b/zulip-dm-relay/src/index.js new file mode 100644 index 000000000..35933cd4d --- /dev/null +++ b/zulip-dm-relay/src/index.js @@ -0,0 +1,63 @@ +// Relay: Zulip outgoing webhook (bot got a DM) -> GitHub repository_dispatch. +// +// Zulip POSTs a JSON body here whenever the configured bot receives a +// message. We check it's really from Zulip (shared token) and that it's a +// DM (not a stream mention), then ask GitHub to fire the "zulip-dm" +// repository_dispatch event, which the repo-updates workflow listens for. + +export default { + async fetch(request, env) { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + let payload; + try { + payload = await request.json(); + } catch { + return new Response("Bad request", { status: 400 }); + } + + // Zulip includes the token you set when creating the outgoing webhook + // bot. This is how we know the request actually came from Zulip and not + // some random caller who found this URL. + if (payload.token !== env.ZULIP_WEBHOOK_TOKEN) { + return new Response("Unauthorized", { status: 401 }); + } + + // Only trigger on direct messages, not @-mentions in a stream. + if (payload.trigger !== "private_message") { + return jsonResponse({}); + } + + const dispatchResp = await fetch( + `https://api.github.com/repos/${env.GITHUB_OWNER}/${env.GITHUB_REPO}/dispatches`, + { + method: "POST", + headers: { + Authorization: `Bearer ${env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "zulip-dm-relay", + }, + body: JSON.stringify({ event_type: "zulip-dm" }), + } + ); + + if (!dispatchResp.ok) { + const errText = await dispatchResp.text(); + console.error("GitHub dispatch failed", dispatchResp.status, errText); + return jsonResponse({ + content: "Sorry, I couldn't trigger the report (GitHub API error).", + }); + } + + return jsonResponse({ content: "Triggered the repo updates report." }); + }, +}; + +function jsonResponse(body) { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/zulip-dm-relay/wrangler.toml b/zulip-dm-relay/wrangler.toml new file mode 100644 index 000000000..245c3573e --- /dev/null +++ b/zulip-dm-relay/wrangler.toml @@ -0,0 +1,11 @@ +name = "zulip-dm-relay" +main = "src/index.js" +compatibility_date = "2026-07-23" + +# Not secret - fine as plain vars. +[vars] +GITHUB_OWNER = "Alex-Zughaid" +GITHUB_REPO = "physlib" + +# Secrets (ZULIP_WEBHOOK_TOKEN, GITHUB_TOKEN) are NOT set here. +# You'll add them with `wrangler secret put` (see instructions).