feat(tools): tangled_pr.py — file a Tangled PR via the appview OAuth web endpoint
Reusable utility (not project-specific): tangled's appview only indexes pulls created through its
OAuth-authenticated web endpoint POST /{owner}/{repo}/pulls/ (it fetches the knot patch + inserts into
its DB directly); raw com.atproto.repo.createRecord does NOT index. This tool reuses a browser session
cookie (gitignored .tangled-session) to POST target/source branch names. No blob upload, no CSRF.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWTdUq2bsic7JZGqJp3nD6
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
result
|
||||
.tangled-session
|
||||
|
||||
89
tangled_pr.py
Executable file
89
tangled_pr.py
Executable file
@ -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:<forkRepoDid>] [--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()
|
||||
Reference in New Issue
Block a user