From 50fc8fd89c57bb329e7c2581ce5d8e7d89ed9020 Mon Sep 17 00:00:00 2001 From: notplants-bot Date: Wed, 12 Aug 2026 04:21:58 +0000 Subject: [PATCH] tangled_comments: read a pull's review comments; secrets: resolve sops off-PATH --- secrets.py | 11 ++++- tangled_comments.py | 103 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tangled_comments.py diff --git a/secrets.py b/secrets.py index 65bf5c3..513376a 100755 --- a/secrets.py +++ b/secrets.py @@ -46,12 +46,21 @@ STORE = os.environ.get("AO_SECRETS_STORE", "/secrets/store.yaml") AGE_KEY = os.environ.get("SOPS_AGE_KEY_FILE", os.path.expanduser("~/.config/sops/age/keys.txt")) +def _sops_bin(): + """Resolve sops. It is on PATH under the systemd unit, but not always in an + interactive shell — fall back to the NixOS system profile before failing.""" + return (shutil.which("sops") + or next((p for p in ("/run/current-system/sw/bin/sops", + "/run/wrappers/bin/sops") if os.path.exists(p)), None) + or "sops") + + def _load(): """Decrypt the store. Fails loudly: a silent empty dict would look like 'no secrets'.""" if not pathlib.Path(STORE).exists(): sys.exit(f"no secret store at {STORE} — see engine/README.md (Secrets)") env = {**os.environ, "SOPS_AGE_KEY_FILE": AGE_KEY} - r = subprocess.run(["sops", "-d", "--output-type", "json", STORE], + r = subprocess.run([_sops_bin(), "-d", "--output-type", "json", STORE], capture_output=True, text=True, env=env) if r.returncode != 0: sys.exit(f"cannot decrypt {STORE} (age key at {AGE_KEY}?): {r.stderr.strip()[:300]}") diff --git a/tangled_comments.py b/tangled_comments.py new file mode 100644 index 0000000..6dc7619 --- /dev/null +++ b/tangled_comments.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Read the review comments on a Tangled pull (top-level discussion comments). + +WHY: the appview has no read API for pull comments, and eyeballing the pull page +HTML per comment is slow. This fetches the pull (or a specific round) and prints +every top-level comment as author / time / body — so acting on operator review +notes is one command, not a WebFetch guess. + +Same session-cookie auth as tangled_pr.py (cookie from the encrypted store, +tangled.cookie). Read-only: it never posts. + +USAGE: + tangled_comments.py --owner notplants-bot.bsky.social --repo lichen.page.review --pull 75 + tangled_comments.py ... --pull 75 --round 2 # a specific round's page + tangled_comments.py ... --pull 75 --json # machine-readable +""" +import argparse, html, json, os, re, sys, urllib.request + +BASE = "https://tangled.org" + + +def load_cookie(): + """Cookie from the encrypted store (tangled.cookie) — see engine/README.md (Secrets).""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import secrets as _store + c = _store.get("tangled.cookie") + if not c: + sys.exit("no tangled.cookie in the secret store — add it with: sops /secrets/store.yaml") + return c + + +def fetch(url, cookie): + req = urllib.request.Request(url, headers={"Cookie": cookie, "User-Agent": "tangled-pr-bot"}) + return urllib.request.urlopen(req, timeout=60).read().decode() + + +def _text(fragment): + """Strip tags from a body fragment down to readable plaintext.""" + t = re.sub(r"(?is)", "\n", fragment) + t = re.sub(r"(?is)", "\n\n", t) + t = re.sub(r"(?is)]*>", "\n- ", t) + t = re.sub(r"(?s)<[^>]+>", "", t) + return html.unescape(t).strip() + + +def parse_comments(doc): + """Return [{cid, author, when, iso, uri, body}] in document order. + + Anchored on the per-comment header block the appview emits: + id="comment-header-{cid}" ... {handle} ... + ... class="...comment-body">
{body}
+ """ + out = [] + heads = list(re.finditer(r'id="comment-header-([0-9a-z]+)"', doc)) + for i, hm in enumerate(heads): + cid = hm.group(1) + # bound by the next comment header, not a fixed window: a comment's own + # reaction/button markup can push its body several KB past the header. + end = heads[i + 1].start() if i + 1 < len(heads) else len(doc) + seg = doc[hm.end(): end] + am = re.search(r'href="/([^"/]+)"[^>]*>\s*([^<]+?)\s*', seg) + author = html.unescape(am.group(2)) if am else "?" + tm = re.search(r'', seg) + iso = html.unescape(tm.group(1)) if tm else "" + when = html.unescape(tm.group(2)) if tm else "" + bm = re.search(r'comment-body">\s*
(.*?)
\s*
\s*
(.*?)
', seg, re.S) + body = _text(bm.group(1)) if bm else "" + out.append({"cid": cid, "author": author, "when": when, "iso": iso, + "uri": f"at://.../sh.tangled.feed.comment/{cid}", "body": body}) + return out + + +def main(): + ap = argparse.ArgumentParser(description="read a Tangled pull's review comments") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--pull", required=True, type=int) + ap.add_argument("--round", type=int, default=None, help="a specific round page (default: latest)") + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + + cookie = load_cookie() + url = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}" + if a.round is not None: + url += f"/round/{a.round}" + doc = fetch(url, cookie) + comments = parse_comments(doc) + + if a.json: + print(json.dumps({"url": url, "count": len(comments), "comments": comments}, indent=2)) + return + print(f"# pull #{a.pull} ({url}) — {len(comments)} comment(s)\n") + for i, c in enumerate(comments, 1): + print(f"[{i}] {c['author']} · {c['when']} ({c['iso']}) #{c['cid']}") + for line in (c["body"] or "(empty)").splitlines(): + print(f" {line}") + print() + + +if __name__ == "__main__": + main()