#!/usr/bin/env python3 """Post a top-level comment on a Tangled pull as the bot. WHY: tangled_comments.py only READS. Replying to the operator's review notes on a ~130-pull stack by hand is not viable, and there is no client API — the appview takes an htmx form POST /comment with three fields: subject-uri at://{did}/sh.tangled.repo.pull/{rkey} (the pull record) pull-round-idx which round the comment hangs off body markdown Both hidden fields are only discoverable from the pull page, so this fetches the page, scrapes them, and posts. Same session-cookie auth as tangled_pr.py. USAGE: tangled_comment_post.py --owner notplants-bot.bsky.social --repo lichen.page.review \ --pull 76 --body "..." # or --body-file reply.md tangled_comment_post.py ... --pull 76 --round 1 --body "..." # pin to a round tangled_comment_post.py ... --pull 76 --body "..." --dry-run """ import argparse, os, re, sys, urllib.error, urllib.parse, urllib.request BASE = "https://tangled.org" def load_cookie(): 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 get(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 scrape_form(doc): """The comment form's two hidden fields. Several rounds' forms can be on the page; take the last (highest round), which is the one the UI shows open.""" uris = re.findall(r'name="subject-uri"[^>]*value="([^"]+)"', doc) idxs = re.findall(r'name="pull-round-idx"[^>]*value="([^"]+)"', doc) if not uris or not idxs: # Two causes, and naming only one sends the reader in the wrong direction: a # reviewer hitting this on a phantom pull will re-auth, succeed, and still fail. sys.exit( "could not find the comment form. Two causes, in likelihood order:\n" " 1. THE PULL DOES NOT EXIST — a 404 page renders 200 and carries no form.\n" " Check the branch on the remote, not the pull number.\n" " 2. the cookie has expired." ) return uris[-1], idxs[-1] def main(): ap = argparse.ArgumentParser(description="post a comment on a Tangled pull") 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="round to attach to (default: latest)") ap.add_argument("--body") ap.add_argument("--body-file") ap.add_argument("--dry-run", action="store_true") a = ap.parse_args() if not a.body and not a.body_file: sys.exit("need --body or --body-file") body = a.body if a.body else open(a.body_file).read() cookie = load_cookie() page = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}" if a.round is not None: page += f"/round/{a.round}" subject_uri, round_idx = scrape_form(get(page, cookie)) if a.dry_run: print(f"would post to {page}\n subject-uri={subject_uri}\n round={round_idx}\n---\n{body}") return data = urllib.parse.urlencode( {"subject-uri": subject_uri, "pull-round-idx": round_idx, "body": body} ).encode() req = urllib.request.Request( f"{BASE}/comment", data=data, headers={ "Cookie": cookie, "User-Agent": "tangled-pr-bot", "Content-Type": "application/x-www-form-urlencoded", "HX-Request": "true", "Referer": page, }, ) try: resp = urllib.request.urlopen(req, timeout=60) except urllib.error.HTTPError as e: sys.exit(f"POST /comment failed: {e.code} {e.read().decode()[:400]}") print(f"posted on pull #{a.pull} (round {round_idx}) — HTTP {resp.status}") if __name__ == "__main__": main()