From 5a993aacba3bdf7bf7324409725bbeaf00ac8636 Mon Sep 17 00:00:00 2001 From: notplants-bot Date: Fri, 31 Jul 2026 15:02:27 +0000 Subject: [PATCH] tangled_pr_edit.py: edit an existing pull's title/body via the appview edit form Sibling of tangled_pr.py. GET/POST /{owner}/{repo}/pulls/{n}/edit (htmx, session cookie); does not mint a round or touch the patch; re-fetches the form after POST and verifies the appview holds the new text. Built and proven by the rust-pr-desc-concise pass (see machine-docs/PR-DESC-CONCISE.md). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BMb31Hx9mnYGRXZ3AL68sk --- tangled_pr_edit.py | 115 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tangled_pr_edit.py diff --git a/tangled_pr_edit.py b/tangled_pr_edit.py new file mode 100644 index 0000000..0ad7756 --- /dev/null +++ b/tangled_pr_edit.py @@ -0,0 +1,115 @@ +#!/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): + if not os.path.exists(path): + sys.exit(f"no cookie file at {path} — do the one-time browser login " + f"(see machine-docs/tangled-pr-automation.md)") + for line in open(path): + line = line.strip() + if line.startswith("TANGLED_COOKIE="): + return line[len("TANGLED_COOKIE="):] + sys.exit("cookie file present but has no TANGLED_COOKIE= line") + +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=os.path.join(os.path.dirname(os.path.abspath(__file__)), ".tangled-session")) + 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()