Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ycNmtHB3N9WZyHcZ6T6Xe
96 lines
3.6 KiB
Python
96 lines
3.6 KiB
Python
#!/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()
|