tangled tools: tangled_pr_resubmit.py — a push does not advance a PR round
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/<r>/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.
This commit is contained in:
@@ -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
|
## Secrets — one encrypted store, never in git
|
||||||
|
|
||||||
**Every credential on an orchestrator host lives in one sops+age encrypted file. Do not put a
|
**Every credential on an orchestrator host lives in one sops+age encrypted file. Do not put a
|
||||||
|
|||||||
Executable
+121
@@ -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/<r>/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 <branch> && tangled_pr_resubmit.py --owner ... --repo ... --pull <n>
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user