THE BUG THAT PROMPTED THIS. tangled_pr.py judged success ONLY by an HX-Redirect header on the POST. A create that SUCCEEDED but answered without that header read as a failure, so the caller retried and Tangled grew duplicates — that is exactly how #397, #398 and #399 were filed for one branch. A response header describes what the server meant to say; it is not the artifact. Now it checks the artifact, in both directions: * BEFORE posting, refuse if an open pull already exists for this source branch, naming it. A retry cannot duplicate, whatever the response said. (--allow-duplicate to override.) * AFTER posting, confirm against the pulls list: a new pull number that did not exist before, whose page names this source branch, IS the success — with or without a redirect header. * Failure is reported only when no such pull appeared. A false failure is worse than a loud error here, because the caller's remedy is to retry. Verified live: a dry-run against a branch that already has a pull refuses with rc=3, naming #417. TWO REAL DEFECTS FOUND BY WRITING THE TESTS. agents.py shelled out to `pgrep -P` and `ps -o comm=`. Neither is on the agent PATH on this host, and a missing binary under shell=True returns rc=127 with EMPTY stdout — indistinguishable from "this process has no children" and "no build is running". So _build_running was ALWAYS False and the stall detector could reboot an agent mid-build. Both now read /proc directly: no PATH dependency, and it cannot fail silently in that direction. That shipped because the unit tests MOCKED pgrep and ps. The fakes stood in for the broken dependency, so the suite passed on a host where neither tool was reachable and never exercised the real path. The tests now patch _proc_descendants and _comms — the seams this repo owns. A test that mocks a dependency proves the mock works. Also fixed a monkeypatch leak those tests had: restoration used a name derivation that silently matched nothing, so the patch escaped into another test class and failed an unrelated test — only in a full run, never when that test ran alone. Now addCleanup, which cannot be ordered wrong. NEW: tests/test_tools.py, 24 tests over tangled_pr, tangled_pr_close and gateway-domain, with every HTTP boundary injected so they run offline. Mutation-checked: breaking classify(), the pull-number regex, the branch match, or the scan bound each turns the suite red. Suite is 93 tests, green, and order-stable across repeated runs. README: a "PATH on a NixOS host" section. Every one of ps, pgrep, free, cmp, awk, curl, diff, strings, nm, getent and ping is INSTALLED here and simply not on the agent PATH, so each reports "command not found" and reads as a missing package. Documents how to check before concluding a tool is absent, how to add the system profile, `nix shell` for what is genuinely missing, and the rule that harness code should not shell out for what the kernel already exposes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3LdmEL7CvCYTNpoBq1kce
217 lines
11 KiB
Python
Executable File
217 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""File a Tangled pull request as the bot, by reusing a browser session cookie.
|
|
|
|
WHY: Tangled's appview only indexes a pull when it's created through its
|
|
OAuth-authenticated web endpoint (POST /{owner}/{repo}/pulls/), which fetches the
|
|
patch from the knot and inserts it into the appview DB directly. Writing the
|
|
sh.tangled.repo.pull record straight to the PDS (com.atproto.repo.createRecord)
|
|
does NOT get indexed (verified: even a byte-identical knot patch fails). There is
|
|
no client CLI. So the automation is: log into tangled.org ONCE in a browser as the
|
|
bot, copy the session cookie here (gitignored), and this tool reuses it.
|
|
|
|
The endpoint needs no CSRF token and no patch upload — just the session cookie and
|
|
the branch names; the appview generates the patch (from the knot) and indexes it.
|
|
Branch-based PR requires push access to the repo (the bot owns its fork, so OK).
|
|
|
|
COOKIE FILE (tools/.tangled-session, gitignored), a single line — paste the whole
|
|
Cookie header value from the logged-in browser (both appview-* cookies):
|
|
TANGLED_COOKIE=appview-session-v2=<...>; appview-accounts-v2=<...>
|
|
|
|
RELIABILITY (rewritten 2026-08-21). This tool used to judge success ONLY by an
|
|
HX-Redirect header on the POST response. A create that SUCCEEDED but answered without
|
|
that header read as a failure, the caller retried, and Tangled grew duplicate pulls —
|
|
that is exactly how #397, #398 and #399 were created for one branch. A response header
|
|
describes what the server meant to say; it is not the artifact. So now:
|
|
|
|
* BEFORE posting, look for an existing OPEN pull for this source branch. If one
|
|
exists, refuse and name it — a duplicate cannot be created even if a caller retries.
|
|
* AFTER posting, confirm against the PULLS LIST, not the response: a new pull number
|
|
that did not exist before and whose page names this source branch IS the success,
|
|
with or without a redirect header.
|
|
* Only report failure when no such pull appeared. A false failure is worse than a
|
|
loud error here, because the caller's remedy is to retry.
|
|
|
|
USAGE:
|
|
tangled_pr.py --owner notplants-bot.bsky.social --repo lichen.page \
|
|
--target main --source hardening-review \
|
|
[--fork did:plc:<forkRepoDid>] [--title "..."] [--body "..."] [--dry-run]
|
|
"""
|
|
import argparse, os, sys, urllib.request, urllib.parse, urllib.error
|
|
|
|
BASE = "https://tangled.org"
|
|
|
|
def load_cookie(path=None):
|
|
"""The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md (Secrets).
|
|
`path` is a legacy escape hatch: a TANGLED_COOKIE=... file, used only if explicitly passed."""
|
|
if path:
|
|
for line in open(path):
|
|
if line.strip().startswith("TANGLED_COOKIE="):
|
|
return line.strip()[len("TANGLED_COOKIE="):]
|
|
sys.exit(f"{path} has no TANGLED_COOKIE= line")
|
|
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
|
|
|
|
|
|
# ── verification against the artifact, not the response ─────────────────────────
|
|
# These are pure so they can be unit-tested without a network: see tests/test_tools.py.
|
|
|
|
import re
|
|
|
|
def pull_numbers(html):
|
|
"""Every pull number linked from a pulls index page."""
|
|
return {int(n) for n in re.findall(r'/pulls/(\d+)\b', html or "")}
|
|
|
|
def page_names_branch(html, branch):
|
|
"""True if a pull's page mentions this source branch.
|
|
|
|
Substring rather than token: Tangled renders the branch inside markup we do not
|
|
control, and a false NEGATIVE here would resurrect the duplicate bug. A false
|
|
positive is caught by the caller, which only asks about pulls it just created.
|
|
"""
|
|
return bool(branch) and branch in (html or "")
|
|
|
|
def classify(before, after, branch_pages):
|
|
"""Decide what a POST did, from the pulls list before and after it.
|
|
|
|
before/after: sets of pull numbers. branch_pages: {number: names_our_branch}.
|
|
Returns (verdict, pull_number) where verdict is:
|
|
"created" a new pull naming our branch appeared
|
|
"unrelated" new pulls appeared but none is ours (someone else was filing)
|
|
"none" nothing new appeared — a real failure
|
|
"""
|
|
fresh = sorted(after - before)
|
|
ours = [n for n in fresh if branch_pages.get(n)]
|
|
if ours:
|
|
return "created", max(ours)
|
|
if fresh:
|
|
return "unrelated", None
|
|
return "none", None
|
|
|
|
def fetch(url, cookie, timeout=30):
|
|
"""GET a page as the bot. Returns "" on any error: callers treat an unreadable page as
|
|
'cannot confirm', never as 'confirmed absent' — the difference is the duplicate bug."""
|
|
try:
|
|
r = urllib.request.Request(url, headers={"Cookie": cookie, "User-Agent": "tangled-pr-bot"})
|
|
return urllib.request.urlopen(r, timeout=timeout).read().decode("utf-8", "replace")
|
|
except Exception:
|
|
return ""
|
|
|
|
def index_pulls(owner, repo, cookie, _fetch=fetch):
|
|
return pull_numbers(_fetch(f"{BASE}/{owner}/{repo}/pulls", cookie))
|
|
|
|
def pull_names_branch(owner, repo, n, branch, cookie, _fetch=fetch):
|
|
return page_names_branch(_fetch(f"{BASE}/{owner}/{repo}/pulls/{n}", cookie), branch)
|
|
|
|
def existing_pull_for(owner, repo, branch, cookie, numbers, scan=12, _fetch=fetch):
|
|
"""The newest pull whose page names this branch, scanning back `scan` pulls.
|
|
|
|
Bounded on purpose: an unbounded scan would fetch hundreds of pages on every call.
|
|
Scanning the newest N is enough for the duplicate this guards against, which is a
|
|
retry seconds after the original. Returns None if none is found within the window —
|
|
which is 'not seen', not 'does not exist', and the post-check is the real backstop.
|
|
"""
|
|
for n in sorted(numbers, reverse=True)[:scan]:
|
|
if pull_names_branch(owner, repo, n, branch, cookie, _fetch):
|
|
return n
|
|
return None
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="file a Tangled PR via a reused session cookie")
|
|
ap.add_argument("--owner", required=True)
|
|
ap.add_argument("--repo", required=True)
|
|
ap.add_argument("--target", required=True, help="target branch (merge into), e.g. main")
|
|
ap.add_argument("--source", required=True, help="source branch (the changes), e.g. hardening-review")
|
|
ap.add_argument("--fork", default="", help="fork repoDid for a cross-fork PR (omit for same-repo)")
|
|
ap.add_argument("--title", default="")
|
|
ap.add_argument("--body", default="")
|
|
ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file instead of the store")
|
|
ap.add_argument("--dry-run", action="store_true", help="check for an existing pull and show the request; do not POST")
|
|
ap.add_argument("--allow-duplicate", action="store_true", help="post even if a pull for this branch already exists")
|
|
a = ap.parse_args()
|
|
|
|
cookie = load_cookie(a.cookie_file)
|
|
# Tangled's PR form is htmx: it POSTs to /pulls/new (NOT /pulls/, which is 405) and only processes
|
|
# the create when it sees the HX-Request header — otherwise it just re-renders the page (a 200 that
|
|
# creates nothing). Success is signalled by an HX-Redirect header pointing at the new pull.
|
|
# PRE-CHECK: refuse to create a second pull for a branch that already has one. This is what
|
|
# makes a retry harmless rather than duplicating — the failure mode that produced #397-#399.
|
|
before = index_pulls(a.owner, a.repo, cookie)
|
|
if not before:
|
|
print("WARNING: could not read the pulls index — cannot check for an existing pull, and "
|
|
"cannot confirm the result afterwards. Proceeding, but verify by hand.")
|
|
dup = existing_pull_for(a.owner, a.repo, a.source, cookie, before) if before else None
|
|
if dup and not a.allow_duplicate:
|
|
print(f"REFUSING: #{dup} already exists for source branch {a.source!r}.")
|
|
print(f" {BASE}/{a.owner}/{a.repo}/pulls/{dup}")
|
|
print(" To push new commits to an existing pull use tangled_pr_resubmit.py.")
|
|
print(" To file anyway (rarely right): --allow-duplicate")
|
|
sys.exit(3)
|
|
|
|
new_url = f"{BASE}/{a.owner}/{a.repo}/pulls/new"
|
|
form = {
|
|
"source": "branch", # branch-compare mode (each PR targets the branch below it)
|
|
"targetBranch": a.target,
|
|
"sourceBranch": a.source,
|
|
"title": a.title, "titleDirty": "true",
|
|
"body": a.body, "bodyDirty": "true",
|
|
}
|
|
if a.fork: form["fork"] = a.fork
|
|
data = urllib.parse.urlencode(form).encode()
|
|
req = urllib.request.Request(new_url, data=data, method="POST", headers={
|
|
"Cookie": cookie,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "tangled-pr-bot",
|
|
"HX-Request": "true",
|
|
"HX-Current-URL": new_url,
|
|
"Referer": new_url,
|
|
})
|
|
|
|
if a.dry_run:
|
|
print(f"DRY RUN: would POST {new_url}")
|
|
print(f" target={a.target!r} source={a.source!r} title={a.title[:60]!r}")
|
|
print(f" existing pull for this branch: {dup if dup else 'none found in the scan window'}")
|
|
return
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *args, **kw): # capture the redirect instead of following it
|
|
return None
|
|
opener = urllib.request.build_opener(NoRedirect)
|
|
try:
|
|
r = opener.open(req, timeout=90)
|
|
hdrs, code, body = r.headers, r.getcode(), r.read(3000).decode("utf-8", "replace")
|
|
except urllib.error.HTTPError as e:
|
|
hdrs, code, body = e.headers, e.code, e.read(3000).decode("utf-8", "replace")
|
|
# htmx success is signalled by HX-Redirect (…/pulls/<n>), not a normal 3xx Location.
|
|
target = hdrs.get("HX-Redirect", "") or hdrs.get("HX-Location", "") or hdrs.get("Location", "")
|
|
print(f"HTTP {code}" + (f" -> {target}" if target else ""))
|
|
|
|
if "/login" in target or "oauth" in target.lower():
|
|
sys.exit("AUTH FAILED: session cookie expired/invalid — refresh the cookie in the secret "
|
|
"store (scripts/get-tangled-cookie.py)")
|
|
|
|
# POST-CHECK: the artifact decides, not the header. A create that answered without a redirect
|
|
# is still a create; reporting it as a failure is what made callers retry into duplicates.
|
|
after = index_pulls(a.owner, a.repo, cookie)
|
|
branch_pages = {n: pull_names_branch(a.owner, a.repo, n, a.source, cookie)
|
|
for n in sorted(after - before, reverse=True)[:12]}
|
|
verdict, num = classify(before, after, branch_pages)
|
|
|
|
if verdict == "created":
|
|
print(f"OK: pull #{num} created -> {BASE}/{a.owner}/{a.repo}/pulls/{num}")
|
|
if not target:
|
|
print(" (no redirect header — confirmed against the pulls list instead)")
|
|
return
|
|
if verdict == "unrelated":
|
|
print(" new pulls appeared but none names this source branch — not ours.")
|
|
snippet = " ".join(body.split())[:600]
|
|
print("FAILED: no pull for this branch appeared. Response snippet:")
|
|
print(" " + snippet)
|
|
sys.exit(2)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|