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>
105 lines
5.2 KiB
Python
Executable File
105 lines
5.2 KiB
Python
Executable File
#!/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=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 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=None, help="legacy: read the cookie from this file instead of the store")
|
|
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/<n>), 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()
|