diff --git a/tangled_comment_post.py b/tangled_comment_post.py new file mode 100644 index 0000000..7b11843 --- /dev/null +++ b/tangled_comment_post.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Post a top-level comment on a Tangled pull as the bot. + +WHY: tangled_comments.py only READS. Replying to the operator's review notes on a +~130-pull stack by hand is not viable, and there is no client API — the appview +takes an htmx form POST /comment with three fields: + subject-uri at://{did}/sh.tangled.repo.pull/{rkey} (the pull record) + pull-round-idx which round the comment hangs off + body markdown +Both hidden fields are only discoverable from the pull page, so this fetches the +page, scrapes them, and posts. Same session-cookie auth as tangled_pr.py. + +USAGE: + tangled_comment_post.py --owner notplants-bot.bsky.social --repo lichen.page.review \ + --pull 76 --body "..." # or --body-file reply.md + tangled_comment_post.py ... --pull 76 --round 1 --body "..." # pin to a round + tangled_comment_post.py ... --pull 76 --body "..." --dry-run +""" +import argparse, os, re, sys, urllib.error, urllib.parse, 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 — add it with: sops /secrets/store.yaml") + 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 scrape_form(doc): + """The comment form's two hidden fields. Several rounds' forms can be on the page; + take the last (highest round), which is the one the UI shows open.""" + uris = re.findall(r'name="subject-uri"[^>]*value="([^"]+)"', doc) + idxs = re.findall(r'name="pull-round-idx"[^>]*value="([^"]+)"', doc) + if not uris or not idxs: + sys.exit("could not find the comment form (is the cookie still valid?)") + return uris[-1], idxs[-1] + + +def main(): + ap = argparse.ArgumentParser(description="post a comment on a Tangled pull") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--pull", required=True, type=int) + ap.add_argument("--round", type=int, default=None, help="round to attach to (default: latest)") + ap.add_argument("--body") + ap.add_argument("--body-file") + ap.add_argument("--dry-run", action="store_true") + a = ap.parse_args() + + if not a.body and not a.body_file: + sys.exit("need --body or --body-file") + body = a.body if a.body else open(a.body_file).read() + + cookie = load_cookie() + page = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}" + if a.round is not None: + page += f"/round/{a.round}" + subject_uri, round_idx = scrape_form(get(page, cookie)) + + if a.dry_run: + print(f"would post to {page}\n subject-uri={subject_uri}\n round={round_idx}\n---\n{body}") + return + + data = urllib.parse.urlencode( + {"subject-uri": subject_uri, "pull-round-idx": round_idx, "body": body} + ).encode() + req = urllib.request.Request( + f"{BASE}/comment", + data=data, + headers={ + "Cookie": cookie, + "User-Agent": "tangled-pr-bot", + "Content-Type": "application/x-www-form-urlencoded", + "HX-Request": "true", + "Referer": page, + }, + ) + try: + resp = urllib.request.urlopen(req, timeout=60) + except urllib.error.HTTPError as e: + sys.exit(f"POST /comment failed: {e.code} {e.read().decode()[:400]}") + print(f"posted on pull #{a.pull} (round {round_idx}) — HTTP {resp.status}") + + +if __name__ == "__main__": + main() diff --git a/tangled_pr_merge.py b/tangled_pr_merge.py new file mode 100644 index 0000000..44d010b --- /dev/null +++ b/tangled_pr_merge.py @@ -0,0 +1,106 @@ +#!/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()