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
107 lines
4.5 KiB
Python
Executable File
107 lines
4.5 KiB
Python
Executable File
#!/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()
|