diff --git a/tangled_pr_close.py b/tangled_pr_close.py new file mode 100755 index 0000000..e30585a --- /dev/null +++ b/tangled_pr_close.py @@ -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()