tangled_pr_close.py: close a pull as the bot, verified against the page

engine/ could create, edit, merge, resubmit and comment on a pull but not close
one. The appview's Close button is an htmx POST to /pulls/{n}/close. This tool
posts it with the session cookie, optionally posts one comment first (the rule
for the site-concurrency series' phase 8: every closed pull names its
successor), refuses to close a MERGED pull, and trusts nothing — it re-fetches
the pull page and requires the badge to read Closed.

First use: lichen.page.review #397-#399, three duplicates of #396.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3aQXnUnx9kncNHQi3c92f
This commit is contained in:
2026-08-18 23:36:04 +00:00
co-authored by Claude Opus 5
parent 64d49b4402
commit bb03bed218
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Close an existing Tangled pull as the bot, via the session cookie.
WHY: engine/ could create, edit, merge, resubmit and comment on a pull, but not CLOSE one. The
appview exposes close as an htmx POST (the "Close" button on the pull page):
POST /{owner}/{repo}/pulls/{n}/close -> closes (hx-swap; success is a 2xx)
Same session-cookie auth as tangled_pr_edit.py. Closing does not create a round and does not
touch the patch. This tool trusts nothing: after the POST it re-fetches the pull page and
confirms the state badge reads Closed. Use --comment-file to post one comment FIRST naming the
successor (the phase-8 rule: every closed pull says which readable PR carries its work).
USAGE:
tangled_pr_close.py --owner notplants-bot.bsky.social --repo lichen.page.review --pull 397 \
[--comment-file note.md] [--dry-run]
"""
import argparse, os, re, subprocess, sys, urllib.error, urllib.parse, urllib.request
BASE = "https://tangled.org"
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
def load_cookie():
import secrets as _store
c = _store.get("tangled.cookie")
if not c:
sys.exit("no tangled.cookie in the secret store — refresh with scripts/get-tangled-cookie.py")
return c
def state_of(owner, repo, pull, cookie):
import tangled_comments as tc
doc = tc.fetch(f"{BASE}/{owner}/{repo}/pulls/{pull}", cookie)
badges = set(re.findall(r'>\s*(Merged|Closed|Open)\s*<', doc))
return badges
def main():
ap = argparse.ArgumentParser(description="close a Tangled pull 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("--comment-file", default=None, help="post this comment before closing")
ap.add_argument("--dry-run", action="store_true", help="show state and the request; do not POST")
a = ap.parse_args()
cookie = load_cookie()
before = state_of(a.owner, a.repo, a.pull, cookie)
print(f"#{a.pull} state before: {sorted(before) or 'unknown'}")
if "Merged" in before:
sys.exit(f"#{a.pull} is MERGED — refusing to close a merged pull")
if "Closed" in before and "Open" not in before:
print(f"#{a.pull} is already Closed; nothing to do")
return
if a.dry_run:
print(f"DRY RUN: would POST {BASE}/{a.owner}/{a.repo}/pulls/{a.pull}/close")
return
if a.comment_file:
r = subprocess.run([sys.executable, os.path.join(HERE, "tangled_comment_post.py"),
"--owner", a.owner, "--repo", a.repo, "--pull", str(a.pull),
"--body-file", a.comment_file], capture_output=True, text=True)
print(r.stdout.strip())
if r.returncode != 0:
sys.exit(f"comment failed, NOT closing: {r.stderr.strip()[:300]}")
close_url = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}/close"
page_url = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}"
req = urllib.request.Request(close_url, data=b"", method="POST", headers={
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "tangled-pr-bot",
"HX-Request": "true",
"HX-Current-URL": page_url,
"Referer": page_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 — refresh with scripts/get-tangled-cookie.py")
if code // 100 != 2:
print(" close did not return 2xx — response snippet:")
print(" " + " ".join(resp.split())[:600])
sys.exit(2)
after = state_of(a.owner, a.repo, a.pull, cookie)
print(f"#{a.pull} state after: {sorted(after) or 'unknown'}")
if "Closed" not in after:
sys.exit("POST returned 2xx but the pull page does not read Closed — check by hand")
print(f"#{a.pull} CLOSED")
if __name__ == "__main__":
main()