Files

107 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Merge or close a Tangled pull as the bot, with a mergeability pre-check.
WHY: `main` advances only by operator-approved merges of the stacked pulls, and the
stack must be merged strictly bottom-up. The appview exposes both actions as bare
htmx POSTs with no body:
POST /{owner}/{repo}/pulls/{n}/merge
POST /{owner}/{repo}/pulls/{n}/close
Neither tells you up front whether the pull is mergeable — that lives in a separate
fragment, GET /{owner}/{repo}/pulls/{n}/round/{r}/actions, which reports "No conflicts,
ready to merge" / "patch is empty" / a conflict. This checks that fragment first and
refuses to merge anything it does not understand, so a bad rung stops the frontier
instead of landing broken.
Note: a pull whose fragment says "patch is empty" (a branch identical to its parent —
e.g. an index slot whose content moved out of the repo) CANNOT be merged; close it.
USAGE:
tangled_pr_merge.py --owner X --repo Y --pull 76 --check
tangled_pr_merge.py --owner X --repo Y --pull 76 --merge
tangled_pr_merge.py --owner X --repo Y --pull 75 --close --reason "empty after restack"
"""
import argparse, os, re, sys, urllib.error, urllib.request
BASE = "https://tangled.org"
def load_cookie():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import secrets as _store
c = _store.get("tangled.cookie")
if not c:
sys.exit("no tangled.cookie in the secret store")
return c
def get(url, cookie):
req = urllib.request.Request(url, headers={"Cookie": cookie, "User-Agent": "tangled-pr-bot"})
return urllib.request.urlopen(req, timeout=60).read().decode()
def post(url, cookie, referer):
req = urllib.request.Request(
url,
data=b"",
headers={
"Cookie": cookie,
"User-Agent": "tangled-pr-bot",
"HX-Request": "true",
"Content-Type": "application/x-www-form-urlencoded",
"Referer": referer,
},
)
return urllib.request.urlopen(req, timeout=120)
def latest_round(doc):
idxs = re.findall(r'name="pull-round-idx"[^>]*value="(\d+)"', doc)
return max((int(i) for i in idxs), default=0)
def state(owner, repo, pull, cookie):
"""(verdict, round) — verdict is the appview's own mergeability text."""
page = get(f"{BASE}/{owner}/{repo}/pulls/{pull}", cookie)
rnd = latest_round(page)
frag = get(f"{BASE}/{owner}/{repo}/pulls/{pull}/round/{rnd}/actions", cookie)
txt = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", frag)).strip()
for phrase in ("No conflicts, ready to merge", "patch is empty", "merged", "conflict"):
if phrase.lower() in txt.lower():
return phrase, rnd, txt
return "UNKNOWN", rnd, txt
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--owner", required=True)
ap.add_argument("--repo", required=True)
ap.add_argument("--pull", required=True, type=int)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--check", action="store_true")
g.add_argument("--merge", action="store_true")
g.add_argument("--close", action="store_true")
ap.add_argument("--reason", default=None, help="close only: logged, not posted")
a = ap.parse_args()
cookie = load_cookie()
verdict, rnd, txt = state(a.owner, a.repo, a.pull, cookie)
print(f"#{a.pull} round {rnd}: {verdict}")
if a.check:
print(f" raw: {txt[:200]}")
return
referer = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}"
if a.merge:
if verdict != "No conflicts, ready to merge":
sys.exit(f"refusing to merge #{a.pull}: appview says {verdict!r}")
r = post(f"{referer}/merge", cookie, referer)
print(f" merged #{a.pull} — HTTP {r.status}")
else:
r = post(f"{referer}/close", cookie, referer)
print(f" closed #{a.pull} — HTTP {r.status}" + (f" ({a.reason})" if a.reason else ""))
if __name__ == "__main__":
main()