Materializing wrote a second plaintext file per secret, which is the problem the store
was meant to solve: two files drift, and the copy is what ends up committed or grepped.
- tangled_pr / tangled_pr_edit / tangled_repo now read tangled.cookie from the store.
engine/.tangled-session is deleted; --cookie-file remains as a legacy escape hatch.
- materialize() is replaced by run-time injection that leaves nothing at rest:
exec-env <group> -- cmd group as env vars (use this instead of a systemd
EnvironmentFile — same effect, no plaintext on disk)
with-file <key> -- cmd {} 0600 file in a private tmpdir, removed when cmd exits,
for consumers that insist on a path (ssh -i, a TLS key)
Co-Authored-By: Claude <noreply@anthropic.com>
121 lines
5.7 KiB
Python
121 lines
5.7 KiB
Python
#!/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'<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=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()
|