From 673eb7ede5490a3f98828cb2e8e224907da04832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Mon, 13 Jul 2026 17:57:12 -0400 Subject: [PATCH 1/7] Add open-Knots-PRs view with ready/tACK/NACK filters --- bosun/github.py | 22 ++++++++ bosun/static_build.py | 26 +++++++++- bosun/web.py | 116 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/bosun/github.py b/bosun/github.py index a5f9bda..1cee129 100644 --- a/bosun/github.py +++ b/bosun/github.py @@ -52,6 +52,28 @@ def pr_url(prnum: str | None) -> str | None: return f"https://github.com/{r[0]}/pull/{r[1]}" if r else None +def list_open_prs(repo: str) -> list[dict]: + """All open (non-draft) PRs in repo, slimmed to what the UI needs. One API + call per 100 PRs; honours GITHUB_TOKEN for rate limits. May raise RateLimited.""" + out: list[dict] = [] + page = 1 + while True: + prs = _get(f"{API}/repos/{repo}/pulls?state=open&per_page=100&page={page}") + for pr in prs: + if pr.get("draft"): + continue + out.append({ + "num": pr["number"], + "title": pr.get("title"), + "updated_at": pr.get("updated_at"), + "labels": [lb["name"] for lb in pr.get("labels", [])], + }) + if len(prs) < 100: + break + page += 1 + return out + + def _token() -> str | None: global _token_cache if _token_cache is not None: diff --git a/bosun/static_build.py b/bosun/static_build.py index 9ca942e..0b20b79 100644 --- a/bosun/static_build.py +++ b/bosun/static_build.py @@ -26,7 +26,7 @@ from . import source, suggest from .github import _read_cache, pr_status, pr_url, resolve_pr from .spec import parse_spec -from .web import _PAGE +from .web import _PAGE, KNOTS_REPO, knots_rows # Specs published on the site. Add entries to publish more. SPECS = [ @@ -66,6 +66,28 @@ def _ingest(entries) -> None: pass +def _build_knots(data, ingest: bool) -> dict: + """Bake the "open Knots PRs" pseudo-spec (data/__knots__.*) and return its + index entry. Same JSON shape as a spec so the frontend needs no new loader.""" + rows = knots_rows(KNOTS_REPO) + if ingest: + for r in rows: + try: + pr_status(KNOTS_REPO, r["lineno"]) + except Exception: + pass + rows = knots_rows(KNOTS_REPO) # re-read with the cache now warm + cats = suggest.categorize(rows) + slim = {k: [{f: e.get(f) for f in _SUG_FIELDS} for e in v] + for k, v in cats.items() if isinstance(v, list)} + (data / "__knots__.json").write_text(json.dumps({"file": "__knots__", "entries": rows})) + (data / "__knots__.suggest.json").write_text( + json.dumps({"known": cats["known"], "total": cats["total"], **slim})) + (data / "__knots__.cleanup.diff").write_text("# not applicable to the PR list\n") + return {"slug": "__knots__", "title": "★ open Knots PRs", "repo": KNOTS_REPO, + "ref": "-", "file": "__knots__", "merges": len(rows), "known": cats["known"]} + + _STATIC_CSS = """ #url, #go, details.src, #auto, #fetchgh, #refresh { display: none !important; } .genat { opacity: .6; font-size: .78rem; margin-left: .5rem; } @@ -76,6 +98,7 @@ def _ingest(entries) -> None: # (render, filters, legend, insights, sugEntry, ...) is reused unchanged. _STATIC_TAIL = r"""loadEntries = async function () { const slug = $("#spec").value; if (!slug) return; + VIEW = slug === "__knots__" ? "prs" : "spec"; $("#status").textContent = "loading…"; try { const j = await (await fetch("data/" + slug + ".json")).json(); @@ -164,6 +187,7 @@ def build(outdir: str, ingest: bool = False) -> None: "merges": len(merges), "known": cats["known"], }) + index["specs"].insert(0, _build_knots(data, ingest)) (data / "index.json").write_text(json.dumps(index)) (out / "index.html").write_text(_static_html()) (out / "CNAME").write_text(CNAME + "\n") diff --git a/bosun/web.py b/bosun/web.py index 1731189..4781f53 100644 --- a/bosun/web.py +++ b/bosun/web.py @@ -17,7 +17,7 @@ from flask import Flask, Response, jsonify, request from . import source, suggest -from .github import RateLimited, _read_cache, pr_status, pr_url, resolve_pr +from .github import RateLimited, _read_cache, list_open_prs, pr_status, pr_url, resolve_pr from .spec import parse_spec app = Flask(__name__) @@ -40,6 +40,36 @@ def _entry_dict(e) -> dict: return d +KNOTS_REPO = "bitcoinknots/bitcoin" + + +def knots_rows(repo: str = KNOTS_REPO) -> list[dict]: + """Open PRs of a Knots repo shaped like spec entries so the same table, + filters and sort work. Review status is read cache-only (no network on load); + run the ingest to populate it.""" + rows = [] + for pr in list_open_prs(repo): + num = pr["num"] + s = _read_cache(repo, num) + rows.append({ + "lineno": num, "section": None, "active": False, "kind": "merge", + "status": None, "status_norm": "untagged", + "prnum": f"k{num}", "name": "-", + "commit": None, "last": None, "upstream": None, + "conflict_patch": None, "comment": None, "raw": "", + "labels": pr["labels"], + "url": f"https://github.com/{repo}/pull/{num}", + "pr_state": s.get("state") if s else "open", + "review_level": s.get("review_level") if s else None, + "acks": s.get("acks") if s else None, + "nacks": s.get("nacks") if s else None, + "pr_title": (s.get("title") if s else None) or pr["title"], + "updated_at": (s.get("updated_at") if s else None) or pr["updated_at"], + "fetched": s.get("fetched") if s else None, + }) + return rows + + @app.get("/") def index() -> Response: html = (_PAGE @@ -112,6 +142,16 @@ def api_cleanup_diff(): return Response(diff, mimetype="text/plain") +@app.get("/api/knots-prs") +def api_knots_prs(): + try: + return jsonify(entries=knots_rows()) + except RateLimited as e: + return jsonify(error=str(e)), 429 + except Exception as e: + return jsonify(error=str(e)), 502 + + @app.get("/api/pr") def api_pr(): repo = request.args.get("repo") @@ -278,12 +318,13 @@ def api_pr():