Redact thread on merge success even if deploy fails; add clear command

A merge whose post-merge deploy failed left the review thread orphaned (redaction was gated on deploy success). Now the thread always redacts on merge success and the deploy-failure warning is posted at top level so it survives. New clear/resolve command redacts an orphaned thread without touching the PR.
This commit is contained in:
Keysat
2026-06-28 21:17:36 -05:00
parent 16faeb06f9
commit 063e6947f7
3 changed files with 48 additions and 25 deletions
+22 -14
View File
@@ -43,14 +43,17 @@ flat JSON in a writable `state/` mount.
- **Onboard a repo:** create a Matrix room named like the repo, invite this bot → it auto-joins, - **Onboard a repo:** create a Matrix room named like the repo, invite this bot → it auto-joins,
maps the room to `<owner>/<roomname>` (+ `~/Projects/<roomname>` on the Mac), and posts an maps the room to `<owner>/<roomname>` (+ `~/Projects/<roomname>` on the Mac), and posts an
onboarding message. Pick review agents in-chat: `agents +reviewer +security -adjudicator`. onboarding message. Pick review agents in-chat: `agents +reviewer +security -adjudicator`.
- **In a review room:** `merge` / `reject` (inside a PR's thread, or `merge <n>` by number), `yes`/`no` - **In a review room:** `merge` / `reject` / `clear` (inside a PR's thread, or `<cmd> <n>` by number),
to confirm a merge, `agents …` to toggle the subagent panel. `yes`/`no` to confirm a merge, `agents …` to toggle the subagent panel. `clear` (alias `resolve`)
redacts an orphaned thread without touching the PR.
## Layout ## Layout
- `src/bot.py` — the bot: matrix-nio sync; auto-join + in-room auto-map (room→repo by name); a Gitea - `src/bot.py` — the bot: matrix-nio sync; auto-join + in-room auto-map (room→repo by name); a Gitea
poll loop per mapped repo; threaded `claude -p` review (subagent panel); merge/reject/deploy poll loop per mapped repo; threaded `claude -p` review (subagent panel); merge/reject/deploy
in-thread; whole-thread redaction on resolve (server-enumerated, restart-proof). in-thread; whole-thread redaction on resolve (server-enumerated, restart-proof) — a merge always
redacts (a failed post-merge deploy leaves a top-level warning, not an orphaned thread); `clear`
redacts without touching the PR.
- `config.example.toml` — homeserver, `[mac]` (ssh alias + the reused matrix-bridge wrapper paths), - `config.example.toml` — homeserver, `[mac]` (ssh alias + the reused matrix-bridge wrapper paths),
`[gitea]` (api_base/owner/verify_tls), `[defaults]`, optional `[repo.<name>]` deploy overrides. `[gitea]` (api_base/owner/verify_tls), `[defaults]`, optional `[repo.<name>]` deploy overrides.
- `.env.example``MATRIX_*` + `GITEA_TOKEN` (real `.env` gitignored). - `.env.example``MATRIX_*` + `GITEA_TOKEN` (real `.env` gitignored).
@@ -82,14 +85,19 @@ revisit this — local inference via Spark Control would be the path.
## Current state ## Current state
**Built + DEPLOYED 2026-06-28; awaiting first real PR.** Bot is running on the Spark (`docker compose **LIVE and proven.** Bot runs on the Spark (`docker compose up -d`), `@reviewer` maps each review room
up -d`), `@reviewer` is in a review room, the room is mapped to a repo, and the subagent panel is to its repo, and the subagent panel is configured in-chat. Proven end-to-end on **ten31-site PR #4**
configured in-chat. Core flow ported from matrix-bridge (D15D19), generalized to multi-repo + the (2026-06-28): threaded review → `merge` + `yes` → force_merge → auto-publish → thread redacted — and
panel. **Not yet exercised on a live PR** — next session: open a test PR and confirm the chain the previously-unproven bit, **headless `claude -p` spawning the reviewer/adjudicator/security
(threaded review → `merge`+`yes` → force_merge → auto-publish → thread redacted). **The one unproven subagents, works**. The ten31-site colleague is onboarded; the pipeline is in steady use.
bit:** headless `claude -p` spawning the reviewer/adjudicator/security subagents (Option B) — if it's
flaky, fall back to bot-orchestrated separate `claude -p` runs (the rejected alternative; see ROADMAP **This session's hardening (NOT yet on the Spark — needs a redeploy: `git fetch && git reset --hard
Phase 2). **Deploy gotchas hit this session:** the Spark needs a *dedicated* per-repo Gitea deploy origin/master && docker compose up -d --build`):** a merge now **always redacts** the thread on merge
key + a `Host` alias (the default `immense-voyage.local` block uses matrix-bridge's key) — clone via success; a failed post-merge deploy posts its "deploy manually" warning at **top level** so it
the alias; Gitea won't reuse one SSH key across repos' deploy keys. A Spark Control tile is captured survives the redaction instead of orphaning the thread (the bug PR #4 hit when the Mac was left off
in the inbox. `master`). New **`clear`/`resolve`** command redacts an orphaned thread without touching the PR.
**Deploy gotcha (still true):** the Spark needs a *dedicated* per-repo Gitea deploy key + a `Host`
alias (the default `immense-voyage.local` block uses matrix-bridge's key); Gitea won't reuse one SSH
key across repos' deploy keys. A Spark Control tile is captured in the inbox. **Next:** redeploy on
the Spark, then clear ten31-site's orphaned PR #4 thread (reply `clear` in it, or `reject`).
+4 -2
View File
@@ -9,8 +9,10 @@ is PR review.
`<owner>/<repo>` (and `~/Projects/<repo>` on the Mac), then posts an onboarding message. `<owner>/<repo>` (and `~/Projects/<repo>` on the Mac), then posts an onboarding message.
2. It polls that repo's open PRs and posts each as a **thread**: a headless `claude -p` review (with 2. It polls that repo's open PRs and posts each as a **thread**: a headless `claude -p` review (with
an optional subagent panel) run on the Mac over SSH. an optional subagent panel) run on the Mac over SSH.
3. In the thread: `merge` (then `yes`) or `reject`. On a clean merge it can auto-deploy, then 3. In the thread: `merge` (then `yes`) or `reject`. A merge auto-deploys (if enabled) and then
redacts the thread so the room shows only open PRs. redacts the thread, so the room shows only open PRs; if the deploy fails, the thread still
clears and a standalone "deploy manually" warning stays in the room. `clear` redacts an
orphaned thread without touching the PR.
Pick which review agents run per room in chat: `agents +reviewer +security -adjudicator`. Pick which review agents run per room in chat: `agents +reviewer +security -adjudicator`.
+22 -9
View File
@@ -446,12 +446,14 @@ async def main():
await say(rid, "🚀 Publishing the merged change to the live site…", root) await say(rid, "🚀 Publishing the merged change to the live site…", root)
rc, out = await run_deploy(room["repo_dir"]) rc, out = await run_deploy(room["repo_dir"])
if rc == 0: if rc == 0:
await say(rid, "✅ Live site updated.", root) print(f"{room['label']} deployed PR #{num}", flush=True)
return True return True
tail = (out or "no output").splitlines()[-1] if out else "no output" tail = (out or "no output").splitlines()[-1] if out else "no output"
print(f"{room['label']} deploy FAILED rc={rc}: {(out or '')[:400]}", flush=True) print(f"{room['label']} deploy FAILED rc={rc}: {(out or '')[:400]}", flush=True)
await say(rid, f"⚠️ Merged, but auto-deploy didn't run (rc={rc}): {tail[:300]} " # Top level (no thread_root) so it survives the thread redaction that
f"— deploy it manually.", root) # follows a merge — the operator still needs this actionable warning.
await say(rid, f"⚠️ PR #{num} merged, but auto-deploy didn't run (rc={rc}): {tail[:300]} "
f"— deploy it manually.")
return False return False
async def do_merge(rid, num): async def do_merge(rid, num):
@@ -460,13 +462,16 @@ async def main():
status, body = await asyncio.to_thread( status, body = await asyncio.to_thread(
lambda: gitea_w("POST", f"/repos/{room['repo']}/pulls/{num}/merge", lambda: gitea_w("POST", f"/repos/{room['repo']}/pulls/{num}/merge",
{"Do": "merge", "force_merge": True})) {"Do": "merge", "force_merge": True}))
if status is not None and 200 <= status < 300: if not (status is not None and 200 <= status < 300):
print(f"merged {room['label']} PR #{num}", flush=True)
if await deploy_after_merge(rid, num):
await redact_thread(rid, num)
else:
detail = gitea_error_message(body) if status else body detail = gitea_error_message(body) if status else body
await say(rid, f"⚠️ merge PR #{num} failed (HTTP {status}): {detail}", root) await say(rid, f"⚠️ merge PR #{num} failed (HTTP {status}): {detail}", root)
return
print(f"merged {room['label']} PR #{num}", flush=True)
# The PR is resolved, so always clear the review thread — a post-merge
# deploy hiccup must not orphan it. deploy_after_merge reports a failure
# at top level, which survives the redaction.
await deploy_after_merge(rid, num)
await redact_thread(rid, num)
async def do_reject(rid, num): async def do_reject(rid, num):
room = rooms[rid] room = rooms[rid]
@@ -510,7 +515,7 @@ async def main():
rooms[rid]["threads"].get(thread_pr)) rooms[rid]["threads"].get(thread_pr))
return return
if cmd not in ("merge", "close", "reject"): if cmd not in ("merge", "close", "reject", "clear", "resolve"):
return return
if thread_pr is not None: if thread_pr is not None:
num = int(thread_pr) num = int(thread_pr)
@@ -527,6 +532,14 @@ async def main():
if cmd == "merge": if cmd == "merge":
pending_merge[(rid, str(num))] = True pending_merge[(rid, str(num))] = True
await say(rid, f"⚠️ Reply `yes` in this thread to merge PR #{num} (or `no` to cancel).", root) await say(rid, f"⚠️ Reply `yes` in this thread to merge PR #{num} (or `no` to cancel).", root)
elif cmd in ("clear", "resolve"):
# Redact an orphaned thread without touching the PR (e.g. it was
# merged outside the bot, or a deploy failure left the thread up).
if not root:
await say(rid, f"No tracked thread for PR #{num} to clear.")
return
pending_merge.pop((rid, str(num)), None)
await redact_thread(rid, num)
else: else:
await do_reject(rid, num) await do_reject(rid, num)