diff --git a/.github/workflows/repo-updates.yml b/.github/workflows/repo-updates.yml new file mode 100644 index 000000000..50c7a69f4 --- /dev/null +++ b/.github/workflows/repo-updates.yml @@ -0,0 +1,36 @@ +name: Repo Updates + +on: + repository_dispatch: + types: [zulip-dm] + 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: + # These GitHub tokens are already configured + GH_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + + # Number of reviews to consider 'busy' + BUSY_THRESHOLD: "3" + + # 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 }} + ZULIP_STREAM: ${{ secrets.ZULIP_STREAM }} + ZULIP_TOPIC: "Repo Updates" + run: python3 scripts/repo-updates-script.py diff --git a/scripts/repo-updates-script.py b/scripts/repo-updates-script.py new file mode 100644 index 000000000..e981cd77b --- /dev/null +++ b/scripts/repo-updates-script.py @@ -0,0 +1,302 @@ +import os +import sys +import json +import datetime +import urllib.request +import urllib.error +import urllib.parse +import base64 + +# Constants +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("/") + +BUSY_THRESHOLD = int(os.environ.get("BUSY_THRESHOLD", "3")) +MAX_PRS_LISTED = int(os.environ.get("MAX_PRS_LISTED", "3")) + +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") + + +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 + + +# 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) + 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 + +# 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"}, + ) + result = [] + for pr in prs: + 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 fetch_recently_merged_prs(): + """Return PRs whose merged_at timestamp falls in the previous UTC calendar day.""" + now = datetime.datetime.now(datetime.timezone.utc) + cutoff = now - datetime.timedelta(hours=24) + + 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 < cutoff: + continue + 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(): + 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 [] + +# get all the important values needed for the message +def build_report(): + prs = fetch_open_prs_in_window() + + 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", [])] + + if not reviewers: + 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( + (pr["number"], pr["title"], pr["html_url"]) + ) + + merged_recently = fetch_recently_merged_prs() + opened_recently = fetch_recently_opened_prs() + + + 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) + 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 { + "busy_threshold": BUSY_THRESHOLD, + "pr_count": len(prs), + "busy": busy, + "moderate": moderate, + "quiet": quiet, + "pending_prs": pending_prs, + "unreviewed_prs": unreviewed_prs, + "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 opened_recently + ], + } + + +# make the message string +def format_message(report): + lines = [] + lines.append( + "Summary of PRs that need attention and available reviewers" + ) + lines.append("") + + def section(title, entries, show_prs=False): + lines.append(f"**{title}** ({len(entries)})") + if not entries: + lines.append("- _none_") + 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 + 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, 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( + f"🔴 Busy (≥{report['busy_threshold']} pending reviews)", + report["busy"], + show_prs=True, + ) + section("🟡 Moderate", report["moderate"]) + section("🟢 Quiet (0 pending reviews)", report["quiet"]) + + # 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: + 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() + # send a DM (Testing only) + '''data = urllib.parse.urlencode( + { + "type": "private", + "to": json.dumps([1175816]), + "content": content, + } + ).encode()''' + + req = urllib.request.Request(f"{ZULIP_SITE}/api/v1/messages", data=data) + credentials = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}" + + 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) + + +def main(): + report = build_report() + message = format_message(report) + print(message) + post_to_zulip(message) + + +if __name__ == "__main__": + main() 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).