Files
agent-orchestrator/tangled_comment_post.py
notplantsandClaude Opus 5 31839b6236 make the read tool refuse a pull that does not exist
A Tangled 404 renders 200 and carries no pull record, so tangled_comments.py's
"0 comment(s)" was indistinguishable from "no such pull". On 2026-08-22 that
produced a false report that a pull was filed and unreviewed, a retracted claim
that the queue tool was broken, and a reviewer's time spent disproving it.

tangled_comment_post.py never had the defect, because it MUST resolve a
subject-uri to function. So the read tool now requires the same identifier and
exits non-zero when the page does not yield one. Proven: #99999 and a
non-existent #428 exit 1 naming the cause, while a real pull still serves its
comments.

This is the structural fix over the vigilance fix, and the argument for it is
that both of us knew the countermeasure and neither applied it — the trap was
already written in one set of notes and in three of the reviewer's own reviews.

And the post tool's error named only the cookie, so anyone hitting it on a
phantom pull would re-auth, succeed, and still fail. It now names both causes in
likelihood order, the pull first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3LdmEL7CvCYTNpoBq1kce
2026-08-22 08:00:16 +00:00

103 lines
4.0 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:
# Two causes, and naming only one sends the reader in the wrong direction: a
# reviewer hitting this on a phantom pull will re-auth, succeed, and still fail.
sys.exit(
"could not find the comment form. Two causes, in likelihood order:\n"
" 1. THE PULL DOES NOT EXIST — a 404 page renders 200 and carries no form.\n"
" Check the branch on the remote, not the pull number.\n"
" 2. the cookie has expired."
)
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()