From 6ee9197fce055920b1024b0103cdcbd2ecba2cec Mon Sep 17 00:00:00 2001 From: notplants-bot Date: Sun, 16 Aug 2026 21:40:26 +0000 Subject: [PATCH] =?UTF-8?q?tangled=20tools:=20tangled=5Fpr=5Fresubmit.py?= =?UTF-8?q?=20=E2=80=94=20a=20push=20does=20not=20advance=20a=20PR=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushing a fixup to the source branch does NOT update the pull: the appview serves the patch it fetched when the pull was opened or last resubmitted, so a reviewer keeps reading the pre-fixup code and no `/round//interdiff` exists. Nothing warns you — the push succeeded, the branch is right, the pull silently lags. It cost a review cycle on 2026-08-16 (three flat-file PRs reviewed against stale trees because "pushed" was read as "resubmitted"). The appview's action is a bare htmx POST with no body, `POST /{owner}/{repo}/pulls/{n}/resubmit`, which re-fetches the branch patch from the knot and opens the next round. The tool wraps it and: - takes several `--pull` numbers at once (a stack-wide rebase touches every rung); - prints the new round and its interdiff URL — what a reply to a reviewer leads with; - says plainly when the round did NOT move (the branch already matched the pull) instead of reporting a silent success; - `--check` reports the current round without posting. It reads the round from the round selector rather than the page text, because a comment that quotes a `/round/2/interdiff` URL would otherwise be counted as a round (that fooled me first). README gains a table of the Tangled tools, which did not exist, and states the trap. --- README.md | 22 ++++++++ tangled_pr_resubmit.py | 121 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100755 tangled_pr_resubmit.py diff --git a/README.md b/README.md index a9566e2..68e21ac 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,28 @@ Run it by hand with `engine/agents.py up --config agents.toml`. --- +## Tangled (the atproto forge) — the PR tools + +One tool per action, all reading `tangled.cookie` from the secret store (`--cookie-file` is a +legacy fallback). They exist because Tangled has no client CLI: each action is an htmx POST to +the appview that only a logged-in session can make. + +| tool | action | +|---|---| +| `tangled_pr.py` | open a pull (branch-based; `--target` is the branch it merges into) | +| `tangled_pr_edit.py` | edit a pull's title/body, or `--show` the current one | +| `tangled_pr_resubmit.py` | **advance a pull to a new round after you pushed a fixup**, and print the interdiff URL | +| `tangled_pr_merge.py` | `--check` mergeability, then `--merge` or `--close` | +| `tangled_comments.py` | read a pull's review comments (`--json` for machine use) | +| `tangled_comment_post.py` | post a comment on a pull | +| `tangled_repo.py` | create a repo as the bot (see also the `tangled-repo` skill) | + +**The trap `tangled_pr_resubmit.py` exists for:** pushing the branch does NOT update the pull. +The appview keeps serving the patch it fetched when the pull was opened or last resubmitted, +so reviewers read the pre-fixup code and no interdiff exists — with no warning, because the +push itself succeeded. Always: push, resubmit, then reply with the printed interdiff URL. See +`machine-docs/PR-WORKFLOW.md`, "A push does NOT advance the round". + ## Secrets — one encrypted store, never in git **Every credential on an orchestrator host lives in one sops+age encrypted file. Do not put a diff --git a/tangled_pr_resubmit.py b/tangled_pr_resubmit.py new file mode 100755 index 0000000..8dad7f2 --- /dev/null +++ b/tangled_pr_resubmit.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Advance a Tangled pull to a new ROUND after you pushed a fixup, and print its interdiff URL. + +WHY THIS EXISTS: pushing to the source branch does NOT update the pull. The appview keeps +serving the diff it fetched when the pull was opened (or last resubmitted), so a reviewer +reading the pull sees the code as it was BEFORE your fixup, and no +`/round//interdiff` exists to show what changed. Nothing warns you — the push +succeeded, the branch is right, and the pull silently lags. That cost a real review cycle +on 2026-08-16: three PRs were reviewed against stale trees because "pushed" was assumed to +mean "resubmitted". + +The appview's own action is a bare htmx POST with no body: + + POST /{owner}/{repo}/pulls/{n}/resubmit + +which re-fetches the branch patch from the knot and opens round N+1. This wraps it, then +reports the new round and the interdiff URL to hand to the reviewer. + +USAGE: + tangled_pr_resubmit.py --owner X --repo Y --pull 376 + tangled_pr_resubmit.py --owner X --repo Y --pull 371 372 374 375 # several, in order + tangled_pr_resubmit.py --owner X --repo Y --pull 376 --check # rounds only, no POST + +AFTER A FIXUP, THE WHOLE DANCE IS: + git push review && tangled_pr_resubmit.py --owner ... --repo ... --pull + then reply on the pull, leading with the printed interdiff URL. +""" +import argparse, os, re, sys, urllib.error, urllib.request + +BASE = "https://tangled.org" + + +def load_cookie(path=None): + """The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md. + `path` is the legacy escape hatch: a file holding a TANGLED_COOKIE=... line.""" + 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 + + +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(owner, repo, pull, cookie): + """The highest round the pull has. Read from the round selector, NOT from the page text: + a comment that quotes a `/round/2/interdiff` URL would otherwise be counted as a round.""" + page = get(f"{BASE}/{owner}/{repo}/pulls/{pull}", cookie) + idxs = re.findall(r'name="pull-round-idx"[^>]*value="(\d+)"', page) + return max((int(i) for i in idxs), default=0) + + +def interdiff_url(owner, repo, pull, rnd): + return f"{BASE}/{owner}/{repo}/pulls/{pull}/round/{rnd}/interdiff?diff=unified" + + +def resubmit(owner, repo, pull, cookie, check_only=False): + before = latest_round(owner, repo, pull, cookie) + if check_only: + print(f"#{pull}: round {before}" + (f" — interdiff {interdiff_url(owner, repo, pull, before)}" if before else " — no interdiff yet (round 0)")) + return 0 + referer = f"{BASE}/{owner}/{repo}/pulls/{pull}" + try: + r = post(f"{referer}/resubmit", cookie, referer) + except urllib.error.HTTPError as e: + print(f"#{pull}: resubmit FAILED — HTTP {e.code} {e.read()[:200]!r}", file=sys.stderr) + return 1 + after = latest_round(owner, repo, pull, cookie) + if after > before: + print(f"#{pull}: round {before} -> {after} (HTTP {r.status})") + print(f" interdiff: {interdiff_url(owner, repo, pull, after)}") + return 0 + # the appview accepted the POST but the round did not move: the branch is identical to + # what the pull already carries. Say so — silence here reads as success. + print(f"#{pull}: still round {after} (HTTP {r.status}) — the branch matches the pull; nothing to resubmit") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description="resubmit a Tangled pull after pushing a fixup") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--pull", required=True, type=int, nargs="+", help="one or more pull numbers") + ap.add_argument("--check", action="store_true", help="report the current round; do not resubmit") + ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file") + a = ap.parse_args() + + cookie = load_cookie(a.cookie_file) + rc = 0 + for pull in a.pull: + rc |= resubmit(a.owner, a.repo, pull, cookie, a.check) + sys.exit(rc) + + +if __name__ == "__main__": + main()