tangled tools: comment-post + merge/close with mergeability gate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ycNmtHB3N9WZyHcZ6T6Xe
This commit is contained in:
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user