diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5994914..269f2b6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,7 +42,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.BOSUN_GH_TOKEN || secrets.GITHUB_TOKEN }} BOSUN_CACHE: .bosun-cache - BOSUN_CACHE_TTL: '259200' # 3 days; keeps daily runs under the token limit + BOSUN_CACHE_TTL: '86400' # 1 day; refresh ACKs each daily run (rate-limited fetches are caught + retried next run) run: python -m bosun.static_build --ingest -o public - name: Upload Pages artifact diff --git a/README.md b/README.md index e8eb9c8..b49035e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ you can see at a glance where each PR stands across `bitcoin/bitcoin`, The core (`spec` / `source` / `github` / `ack` / `suggest` / `report` / `daggy`) is pure stdlib; only the web UI needs Flask. +Live instance: + ## Web app ```bash @@ -19,10 +21,14 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt .venv/bin/python -m bosun.web --repo me/tmp --ref my-specs # default a fork ``` -Pick a spec from the dropdown (defaults to `luke-jr/tmp` @ `knots-spec`), or set -`repo`/`ref` to browse your own fork. Click the legend chips to filter by state -or canonical bucket, click column headers to sort, and **fetch GitHub status -(shown)** to pull live PR state + 0-3 review level for the visible rows. +Pick a spec from the dropdown (defaults to +[`luke-jr/tmp` @ `knots-spec`](https://github.com/luke-jr/tmp/tree/knots-spec)), or +choose **★ open Knots PRs** to triage every open PR in `bitcoinknots/bitcoin`, +including ones not yet in any spec. Filter by source (Core/Knots/GUI), GitHub +labels, review level, or the **ready** quick-filter (open + tested ACK, no NACK); +sort by any column; and **fetch GitHub status (shown)** to pull live PR state and +the 0-3 review level for the visible rows. Or set `repo`/`ref` to browse your own +fork. Auth uses `GITHUB_TOKEN`, falling back to `gh auth token`; without either the GitHub limit is 60 requests/hour. Bulk-prefetch a whole spec into the cache: @@ -44,7 +50,8 @@ python3 -m http.server -d public 8000 # preview at :8000 ``` Deploy is via `.github/workflows/deploy.yml` (push, daily cron, manual dispatch). -Add published specs in `SPECS` in `bosun/static_build.py`. +The open-Knots-PRs view is baked automatically; add more published specs in +`SPECS` in `bosun/static_build.py`. ## Maintenance digest (markdown, for the terminal) @@ -58,9 +65,10 @@ python3 -m bosun.report --file knots-next-29.spec -o digest.md ## Daggy-fix helper (for the traditional-git model) -For the proposed DAG-native Knots model, the core operation is: develop a fix on -master, rebase it back to the oldest affected commit, then merge it forward into -each live branch. This automates the two parts that aren't trivial: +For the [proposed DAG-native Knots model](https://gist.github.com/chrisguida/65337b84ffb4acbd09aa5ba073b55d00), +the core operation is: develop a fix on master, rebase it back to the oldest +affected commit, then merge it forward into each live branch. This automates the +two parts that aren't trivial: ```bash # Where do I rebase back to? Bisect to the bug-introducing commit using a 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..2f10e5f 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") @@ -195,6 +235,8 @@ def api_pr(): .st-closed{background:#c0303055} .st-missing,.st-unknown{background:#8888882a} .lv { display: inline-block; font-size: .78em; padding: .08rem .4rem; border-radius: .5rem; background:#8888882a; } .lv2 { background:#c8881144 } .lv3 { background:#2a8a2a55 } .nackt { color:#c03030 } + .src-core{background:#2b6cb055} .src-knots{background:#c8811155} .src-gui{background:#7c3aed55} + .lbl{background:#80808026} .sub { opacity: .7; margin-bottom: .4rem; font-size: .9rem; } #status { font-style: italic; } @@ -278,12 +320,15 @@ def api_pr():