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)