#!/usr/bin/env python3 """Edit an existing Tangled pull's title/body as the bot, via the session cookie. WHY: engine/tangled_pr.py only CREATES pulls; the appview also exposes an edit endpoint for an existing pull (the pencil on the pull page). It is an htmx form: GET /{owner}/{repo}/pulls/{n}/edit -> the form (current title + body) POST /{owner}/{repo}/pulls/{n}/edit -> apply (form fields: title, body) Same session-cookie auth as tangled_pr.py. Editing title/body does NOT create a round and does not touch the patch — but callers should re-fetch and verify that themselves (see --show). COOKIE FILE (engine/.tangled-session, gitignored) — same as tangled_pr.py; on 401/login redirect refresh it with scripts/get-tangled-cookie.py. USAGE: tangled_pr_edit.py --owner notplants-bot.bsky.social --repo lichen.page.review \ --pull 75 --show # print current title + body (raw md) tangled_pr_edit.py ... --pull 75 --title "..." --body "..." # apply edit tangled_pr_edit.py ... --pull 75 --title "..." --body-file b.md # body from file """ import argparse, html, os, re, sys, urllib.request, urllib.parse, urllib.error BASE = "https://tangled.org" def load_cookie(path=None): """The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md (Secrets). `path` is a legacy escape hatch: a TANGLED_COOKIE=... file, used only if explicitly passed.""" if path: for line in open(path): if line.strip().startswith("TANGLED_COOKIE="): return line.strip()[len("TANGLED_COOKIE="):] sys.exit(f"{path} has no TANGLED_COOKIE= line") 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_form(edit_url, cookie): """GET the htmx edit form; return (title, body) as the appview holds them.""" req = urllib.request.Request(edit_url, headers={ "Cookie": cookie, "User-Agent": "tangled-pr-bot", "HX-Request": "true", "HX-Current-URL": edit_url, "Referer": edit_url, }) doc = urllib.request.urlopen(req, timeout=60).read().decode() tm = re.search(r'name="title" id="title"[^>]*value="([^"]*)"', doc) bm = re.search(r'\n?(.*?)', doc, re.S) if not tm or not bm: sys.exit("could not parse the edit form (auth expired? layout changed?)") return html.unescape(tm.group(1)), html.unescape(bm.group(1)) def main(): ap = argparse.ArgumentParser(description="edit a Tangled pull's title/body via a reused session cookie") ap.add_argument("--owner", required=True) ap.add_argument("--repo", required=True) ap.add_argument("--pull", required=True, type=int) ap.add_argument("--title", default=None) ap.add_argument("--body", default=None) ap.add_argument("--body-file", default=None, help="read the new body from a file (overrides --body)") ap.add_argument("--show", action="store_true", help="print current title+body and exit (no edit)") ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file instead of the store") a = ap.parse_args() cookie = load_cookie(a.cookie_file) edit_url = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}/edit" if a.show: title, body = fetch_form(edit_url, cookie) print(f"TITLE: {title}") print("BODY:") print(body) return body = open(a.body_file).read() if a.body_file else a.body if a.title is None or body is None: sys.exit("need --title and --body/--body-file (or --show)") # the edit form is htmx like the create form: needs HX-Request or the POST # just re-renders; success is a 2xx (hx-swap=none), failure a login redirect data = urllib.parse.urlencode({"title": a.title, "body": body}).encode() req = urllib.request.Request(edit_url, data=data, method="POST", headers={ "Cookie": cookie, "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "tangled-pr-bot", "HX-Request": "true", "HX-Current-URL": edit_url, "Referer": edit_url, }) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kw): return None opener = urllib.request.build_opener(NoRedirect) try: r = opener.open(req, timeout=90) hdrs, code, resp = r.headers, r.getcode(), r.read(3000).decode("utf-8", "replace") except urllib.error.HTTPError as e: hdrs, code, resp = e.headers, e.code, e.read(3000).decode("utf-8", "replace") target = hdrs.get("HX-Redirect", "") or hdrs.get("HX-Location", "") or hdrs.get("Location", "") print(f"HTTP {code}" + (f" -> {target}" if target else "")) if "/login" in target or "oauth" in target.lower(): sys.exit("AUTH FAILED: session cookie expired/invalid — refresh engine/.tangled-session " "(scripts/get-tangled-cookie.py)") if code // 100 != 2: snippet = " ".join(resp.split())[:600] print(" edit did not return 2xx — response snippet:") print(" " + snippet) sys.exit(2) # trust nothing: re-fetch the form and confirm the appview now holds the new text new_title, new_body = fetch_form(edit_url, cookie) if new_title == a.title and new_body.replace("\r\n", "\n") == body.replace("\r\n", "\n"): print("OK: verified — appview now holds the new title/body") else: print(f"MISMATCH after edit: title_ok={new_title == a.title} " f"body_ok={new_body.replace(chr(13)+chr(10), chr(10)) == body.replace(chr(13)+chr(10), chr(10))}") sys.exit(3) if __name__ == "__main__": main()