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()