#!/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()