#!/usr/bin/env python3 """File a Tangled pull request as the bot, by reusing a browser session cookie. WHY: Tangled's appview only indexes a pull when it's created through its OAuth-authenticated web endpoint (POST /{owner}/{repo}/pulls/), which fetches the patch from the knot and inserts it into the appview DB directly. Writing the sh.tangled.repo.pull record straight to the PDS (com.atproto.repo.createRecord) does NOT get indexed (verified: even a byte-identical knot patch fails). There is no client CLI. So the automation is: log into tangled.org ONCE in a browser as the bot, copy the session cookie here (gitignored), and this tool reuses it. The endpoint needs no CSRF token and no patch upload — just the session cookie and the branch names; the appview generates the patch (from the knot) and indexes it. Branch-based PR requires push access to the repo (the bot owns its fork, so OK). COOKIE FILE (tools/.tangled-session, gitignored), a single line — paste the whole Cookie header value from the logged-in browser (both appview-* cookies): TANGLED_COOKIE=appview-session-v2=<...>; appview-accounts-v2=<...> USAGE: tangled_pr.py --owner notplants-bot.bsky.social --repo lichen.page \ --target main --source hardening-review \ [--fork did:plc:] [--title "..."] [--body "..."] """ import argparse, os, 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 main(): ap = argparse.ArgumentParser(description="file a Tangled PR via a reused session cookie") ap.add_argument("--owner", required=True) ap.add_argument("--repo", required=True) ap.add_argument("--target", required=True, help="target branch (merge into), e.g. main") ap.add_argument("--source", required=True, help="source branch (the changes), e.g. hardening-review") ap.add_argument("--fork", default="", help="fork repoDid for a cross-fork PR (omit for same-repo)") ap.add_argument("--title", default="") ap.add_argument("--body", default="") 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) # Tangled's PR form is htmx: it POSTs to /pulls/new (NOT /pulls/, which is 405) and only processes # the create when it sees the HX-Request header — otherwise it just re-renders the page (a 200 that # creates nothing). Success is signalled by an HX-Redirect header pointing at the new pull. new_url = f"{BASE}/{a.owner}/{a.repo}/pulls/new" form = { "source": "branch", # branch-compare mode (each PR targets the branch below it) "targetBranch": a.target, "sourceBranch": a.source, "title": a.title, "titleDirty": "true", "body": a.body, "bodyDirty": "true", } if a.fork: form["fork"] = a.fork data = urllib.parse.urlencode(form).encode() req = urllib.request.Request(new_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": new_url, "Referer": new_url, }) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kw): # capture the redirect instead of following it return None opener = urllib.request.build_opener(NoRedirect) try: r = opener.open(req, timeout=90) hdrs, code, body = r.headers, r.getcode(), r.read(3000).decode("utf-8", "replace") except urllib.error.HTTPError as e: hdrs, code, body = e.headers, e.code, e.read(3000).decode("utf-8", "replace") # htmx success is signalled by HX-Redirect (…/pulls/), not a normal 3xx Location. target = hdrs.get("HX-Redirect", "") or hdrs.get("HX-Location", "") or hdrs.get("Location", "") print(f"HTTP {code}" + (f" -> {target}" if target else "")) if target and "/pulls/" in target and "/new" not in target: print("OK: pull created ->", (BASE + target) if target.startswith("/") else target) return 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)") # otherwise surface whatever the page said (a Notice, etc.) snippet = " ".join(body.split())[:600] print(" no clear success redirect — response snippet:") print(" " + snippet) sys.exit(2) if __name__ == "__main__": main()