diff --git a/.gitignore b/.gitignore index 835a235..91edba0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ *.pyc result +.tangled-session diff --git a/tangled_pr.py b/tangled_pr.py new file mode 100755 index 0000000..f60d840 --- /dev/null +++ b/tangled_pr.py @@ -0,0 +1,89 @@ +#!/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) + url = f"{BASE}/{a.owner}/{a.repo}/pulls/" + form = {"targetBranch": a.target, "sourceBranch": a.source} + if a.fork: form["fork"] = a.fork + if a.title: form["title"] = a.title + if a.body: form["body"] = a.body + data = urllib.parse.urlencode(form).encode() + req = urllib.request.Request(url, data=data, method="POST", headers={ + "Cookie": cookie, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "tangled-pr-bot", + "Referer": f"{BASE}/{a.owner}/{a.repo}/pulls/new", + }) + + 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) + code, loc, body = r.getcode(), r.headers.get("Location", ""), r.read(3000).decode("utf-8", "replace") + except urllib.error.HTTPError as e: + code, loc, body = e.code, e.headers.get("Location", ""), e.read(3000).decode("utf-8", "replace") + + print(f"HTTP {code}" + (f" -> {loc}" if loc else "")) + if code in (301, 302, 303, 307, 308) and "/pulls/" in loc and "/new" not in loc: + print("OK: pull created ->", (BASE + loc) if loc.startswith("/") else loc) + return + if code in (301, 302, 303) and ("/login" in loc or "oauth" in loc): + sys.exit("AUTH FAILED: session cookie expired/invalid — redo the one-time browser login " + "and update tools/.tangled-session") + # 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()