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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMb31Hx9mnYGRXZ3AL68sk
This commit is contained in:
@@ -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'<textarea\s+name="body".*?>\n?(.*?)</textarea>', 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()
|
||||
Reference in New Issue
Block a user