#!/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()