11 KiB
gitea-review-bot: AGENTS.md
gitea-review-bot is a thin Matrix bot that runs an AI PR-review-and-merge workflow for your Gitea
repos, one review room per repo. It is a separate bot from matrix-bridge (which turns Matrix
messages into interactive Claude Code sessions): this one's single responsibility is reviewing
pull requests — so every room it's in is a review room and there is no manual "ignore this room"
wiring. Invite it to a room, it auto-joins and maps the room to a repo by name, then for that repo
it polls Gitea PRs, posts a headless claude -p review as a Matrix thread, and lets you merge/reject
from the thread.
Inbox check: At session start, if
~/Projects/standards/INBOX.mdexists, scan it for items tagged(gitea-review-bot)and surface them before proposing next steps; triage with/triage.
Stack
Python + matrix-nio, one thin Docker container on the Spark (same shape as matrix-bridge and
the ten31-database intake bot). No framework (Maubot rejected — see Decisions). The heavy work —
the claude -p review and any deploy — runs on the Mac over SSH, reusing matrix-bridge's proven
wrappers (scripts/ask-claude.sh for the review, scripts/deploy-site.sh for publish). State is
flat JSON in a writable state/ mount.
Placement
| Dimension | Call |
|---|---|
| Host | Spark, plain Docker container (NOT Start9/s9pk) |
| Runtime | Long-running service: matrix-nio sync + a Gitea poll loop over mapped repos |
| Model routing | claude -p on the Mac via the Spark→Mac SSH seam (subscription); the review session spawns subagents (reviewer / adjudicator / security-auditor) |
| Data layer | Flat JSON in state/ (room→repo map + enabled agents + reviewed-PR heads + thread roots) |
| Interface | Matrix — one review room per repo (+ phone) |
| Repo home | Local + Gitea (ssh://git@immense-voyage.local:59916/grant/gitea-review-bot.git) |
| Sensitivity | Sends PR diffs to claude -p (subscription). Fine for code review; flag the boundary before pointing it at a sensitive repo. |
Commands
- Run (container, on the Spark): from
~/gitea-review-bot,docker compose up -d --build(host networking,restart: unless-stopped; read-only mounts of.env/config.toml/SSH key, read-writestate/). Logs:docker compose logs -f. - Deploy: the Spark's
~/gitea-review-botis a Gitea clone trackingmaster; deploy =git fetch && git reset --hard origin/master && docker compose up -d --build, or one click on this bot's tile in Spark Control's Bots tab (shipped since spark-control v0.28.0, confirmed live: status badge, Update, Start/Restart/Stop, View logs; the tile is hidden unlessGITEA_REVIEW_USERis set in its Settings).config.tomlis gitignored — refresh it on the Spark separately (scp) like matrix-bridge. Reach the Spark from the Mac asssh modelo32(the NVIDIA Sync alias — hostspark-32d0, usermodelo, key-based; injected into~/.ssh/configvia anInclude.modelo27/spark-27eais a second Spark on another LAN).reset --hardis safe:.env/config.toml/state/are gitignored. Deploy-key gotcha: each repo the Spark clones needs its own Gitea deploy key plus its ownHostalias — Gitea refuses to reuse one SSH key across two repos' deploy keys, and the defaultimmense-voyage.localblock already carries matrix-bridge's key. - 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 onboarding message. Pick review agents in-chat:agents +reviewer +security -adjudicator(a new room's panel starts empty — the lead review still runs, it just convenes no subagents). No restart needed: the poll loop re-readslist(rooms.keys())every cycle, so a room mapped mid-run is polled within one interval. The startup log line ("polling N mapped repo(s)") is stale from then on — it is printed once and never re-printed; don't read it as the live room count. - In a review room:
merge/reject/clear(inside a PR's thread, or<cmd> <n>by number),yes/noto confirm a merge,agents …to toggle the subagent panel.clear(aliasresolve) redacts an orphaned thread without touching the PR. - Merging under branch protection: these repos protect
masterwith push + merge whitelists (grantonly) andrequired_approvals = 1. The bot never posts an approving review to Gitea, so a collaborator's PR page shows as blocked pending approval — the in-threadmergestill lands it, because the bot merges withforce_merge: true(proven on ten31-site PR #4 against this exact rule shape). Tell a collaborator that "1 approval required" is expected, not stuck. Post-merge publishing is opt-in per repo ([repo.<name>].deploy_on_merge); without it a merge merges and redacts, nothing more. - Prove the poll loop is alive (silence is normal — the loop logs only on a review or an error, so
a healthy bot can go days without a line). Sample the Spark's host socket table fast enough to catch
a poll:
end=$((SECONDS+130)); while [ $SECONDS -lt $end ]; do case "$(cat /proc/net/tcp)" in *EE73*) echo hit;; esac; sleep 0.01; done—EE73is Gitea's port 61043 in hex, and two hits ~60s apart = healthy. Why the fast sampling: the poll socket lives milliseconds and leaves noTIME_WAIT(Gitea closes first), so 1–2s polling misses it entirely and reads as a false wedge. Also: the container has nops,sudoon the Spark wants a password (notcpdump), and host networking means the container's/proc/1/net/tcpis the host's. This is the observation the no-heartbeat decision waits on.
Layout
src/bot.py— the bot: matrix-nio sync; auto-join + in-room auto-map (room→repo by name); a Gitea poll loop that walks all mapped repos in one async task, serially (so rooms never interleave, but a long review head-of-line-blocks the next room's poll: the scaling limit to watch at 3+ repos, and what the deferred poll→webhook item addresses); threadedclaude -preview (subagent panel); merge/reject/deploy 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);clearredacts 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.<name>]deploy overrides..env.example—MATRIX_*+GITEA_TOKEN(real.envgitignored). The live token is scopedwrite:repositoryonly: repo/PR calls work, but/userand anything needingread:user403s.Dockerfile·docker-compose.yml·docker-entrypoint.sh— the Spark container (generic image; secrets/config via read-only mounts; entrypoint writes~/.ssh/configfor the Mac alias).state/— gitignored runtime JSON (rooms.json: room map + agents + heads + thread roots).
Decisions
- Separate bot from matrix-bridge (single responsibility = PR review). Beat: extending matrix-bridge with review-room special-casing. Reopens if the two bots' logic heavily overlaps.
- Thin matrix-nio container, NOT Maubot. Reevaluated 2026-06-28: Maubot helps with the Matrix
plumbing we've already solved, not the SSH/
claude -p/poll logic that's the actual weight, and it reintroduces a web-UI/management layer (Spark Control is the dashboard). Reopens at ~6+ bots or a non-developer web-management need; the lighter step first is a shared "bot kit" library. - Subagent panel (Option B): the lead
claude -psession spawns subagents and presents each output + its own overall recommendation. Beat: bot-orchestrated separateclaude -pruns (more deterministic but 3× the sessions + more bot code). Reopens if headless subagent spawning is flaky. - Panel composition is per-room, set in chat (onboarding message +
agents +/-), not config. - Reuse matrix-bridge's Mac wrappers + Spark→Mac SSH key (don't duplicate the seam).
- Auto-map by name: room
<x>→ Gitea<owner>/<x>+~/Projects/<x>; mapping persists tostate/(mirrors matrix-bridge D14). One room per repo. - No wedge-detection heartbeat (adjudicated 2026-07-29). A healthcheck badge restarts nothing
under this compose setup, the tile that would show it lives in spark-control (so it's a two-repo
change), and serial per-room polling with a 10-min review budget makes any staleness threshold
either too loose to be timely or noisy mid-review. Reopens only on an observed wedge plus
confirmation the spark-control tile already renders Docker health. The cheaper half of the same
goal is the ROADMAP fold-in: bound the unbounded awaits and die if the poll task dies, so
restart: unless-stoppedhandles it.
Sovereignty
Reviews send PR diffs to claude -p (the subscription), not a frontier API on payload data; that's
acceptable for code review of these repos. Before pointing the bot at a repo with sensitive content,
revisit this — local inference via Spark Control would be the path.
Current state
LIVE and in steady use, now serving two review rooms. Verified 2026-08-18: the container has been
up since 08-13 with zero restarts on 063e694, and the poll loop is genuinely alive (two Gitea connections
observed exactly 60s apart — see the liveness recipe in Commands). master is only docs ahead, so no
redeploy is pending. No test suite: verification means exercising the bot in a real review room.
- ten31-site — proven end-to-end on PR #4 (2026-06-28: review →
merge+yes→ force_merge → auto-publish → redacted). PR #5 "Update index.html" is still open, reviewed 2026-06-30, its thread live in the room and waiting on amerge/reject. - ten31-brand — onboarded 2026-08-18 to watch the interactive deck
(
design/deck/interactive/index.html). Panel is empty; no path filter (deliberate — the repo is small enough that every PR is worth seeing). It has had zero PRs ever: all work went straight tomaster, and the bot sees only pull requests. Collaboratorjonathanhas write access but is blocked from pushing tomaster, so his changes must arrive as PRs; the owner's own pushes still bypass the bot.
Next steps, in priority order:
- Fix the orphaned-thread prune (
src/bot.py~L594): the poll loop drops a PR's thread root from state when the PR leaves the open list without redacting, so a PR merged or closed in the Gitea web UI strands its thread andclearcan no longer reach it. More likely to bite now with two rooms. /triagethe 3 untriaged inbox items tagged for this repo — adjudication never reads the inbox, so their content is still unknown here.- With a 2nd repo mapped, ROADMAP Phase 3's verify is unblocked: next time both rooms have open PRs at
once, watch a cycle (
docker compose logs -f) for a long review head-of-line-blocking the other room.