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:
Executable
+106
@@ -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()
|
||||
Reference in New Issue
Block a user