diff --git a/AGENTS.md b/AGENTS.md index 3e784f0..4296dbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, maps the room to `/` (+ `~/Projects/` on the Mac), and posts an onboarding message. Pick review agents in-chat: `agents +reviewer +security -adjudicator`. -- **In a review room:** `merge` / `reject` (inside a PR's thread, or `merge ` by number), `yes`/`no` - to confirm a merge, `agents …` to toggle the subagent panel. +- **In a review room:** `merge` / `reject` / `clear` (inside a PR's thread, or ` ` by number), + `yes`/`no` to confirm a merge, `agents …` to toggle the subagent panel. `clear` (alias `resolve`) + redacts an orphaned thread without touching the PR. ## Layout - `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 - 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), `[gitea]` (api_base/owner/verify_tls), `[defaults]`, optional `[repo.]` deploy overrides. - `.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 -**Built + DEPLOYED 2026-06-28; awaiting first real PR.** Bot is running on the Spark (`docker compose -up -d`), `@reviewer` is in a review room, the room is mapped to a repo, and the subagent panel is -configured in-chat. Core flow ported from matrix-bridge (D15–D19), generalized to multi-repo + the -panel. **Not yet exercised on a live PR** — next session: open a test PR and confirm the chain -(threaded review → `merge`+`yes` → force_merge → auto-publish → thread redacted). **The one unproven -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 -Phase 2). **Deploy gotchas hit this session:** the Spark needs a *dedicated* per-repo Gitea deploy -key + a `Host` alias (the default `immense-voyage.local` block uses matrix-bridge's key) — clone via -the alias; Gitea won't reuse one SSH key across repos' deploy keys. A Spark Control tile is captured -in the inbox. +**LIVE and proven.** Bot runs on the Spark (`docker compose up -d`), `@reviewer` maps each review room +to its repo, and the subagent panel is configured in-chat. Proven end-to-end on **ten31-site PR #4** +(2026-06-28): threaded review → `merge` + `yes` → force_merge → auto-publish → thread redacted — and +the previously-unproven bit, **headless `claude -p` spawning the reviewer/adjudicator/security +subagents, works**. The ten31-site colleague is onboarded; the pipeline is in steady use. + +**This session's hardening (NOT yet on the Spark — needs a redeploy: `git fetch && git reset --hard +origin/master && docker compose up -d --build`):** a merge now **always redacts** the thread on merge +success; a failed post-merge deploy posts its "deploy manually" warning at **top level** so it +survives the redaction instead of orphaning the thread (the bug PR #4 hit when the Mac was left off +`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`). diff --git a/README.md b/README.md index 3d33187..8d42885 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ is PR review. `/` (and `~/Projects/` 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 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 - redacts the thread so the room shows only open PRs. +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; 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`. diff --git a/src/bot.py b/src/bot.py index d40c118..f4efc57 100644 --- a/src/bot.py +++ b/src/bot.py @@ -446,12 +446,14 @@ async def main(): await say(rid, "🚀 Publishing the merged change to the live site…", root) rc, out = await run_deploy(room["repo_dir"]) if rc == 0: - await say(rid, "✅ Live site updated.", root) + print(f"{room['label']} deployed PR #{num}", flush=True) return True 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) - await say(rid, f"⚠️ Merged, but auto-deploy didn't run (rc={rc}): {tail[:300]} " - f"— deploy it manually.", root) + # Top level (no thread_root) so it survives the thread redaction that + # 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 async def do_merge(rid, num): @@ -460,13 +462,16 @@ async def main(): status, body = await asyncio.to_thread( lambda: gitea_w("POST", f"/repos/{room['repo']}/pulls/{num}/merge", {"Do": "merge", "force_merge": True})) - if 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: + if not (status is not None and 200 <= status < 300): detail = gitea_error_message(body) if status else body 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): room = rooms[rid] @@ -510,7 +515,7 @@ async def main(): rooms[rid]["threads"].get(thread_pr)) return - if cmd not in ("merge", "close", "reject"): + if cmd not in ("merge", "close", "reject", "clear", "resolve"): return if thread_pr is not None: num = int(thread_pr) @@ -527,6 +532,14 @@ async def main(): if cmd == "merge": pending_merge[(rid, str(num))] = True 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: await do_reject(rid, num)