AGENTS.md had grown 72% in one session, most of it push-watch mechanics that only matter when editing src/bot.py. Those move to docs/guides/push-watch.md (paths-scoped, symlinked from .claude/rules, matching the ten31-database and spark-control convention); AGENTS.md keeps the operational half and a pointer. Current state goes back to being a snapshot rather than a session log.
15 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 watching what
lands in a repo — 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. Opt-in per repo, it also watches commits pushed by anyone but me and posts an
assessment of what they did (see the push watch in Commands).
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.known_hostslives inside the image, so it resets on every rebuild — the ssh config setsLogLevel ERRORso the first connection's "Permanently added … to the list of known hosts" warning doesn't get prepended to a review or assessment (it did, visibly, on 2026-08-21). - 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. - Push watch (opt-in per repo): notifies whenever someone who isn't me pushes commits, and posts
a
claude -passessment of what they did as a threaded reply. Turn on with[repo.<name>].watch_pushes = trueplus[gitea] my_emails(my commit emails — anyone not on that list notifies, so an unrecognised address surfaces rather than slips through). Optional[repo.<name>].expected_pathsglobs mark the collaborator's expected territory; files outside it are flagged in the notice, computed from the file list rather than asked of the model. Scope is every branch except ones with an open PR. On first run it arms at "now" and never replays history. Push notices are not redactable (clearonly reaches PR threads), so they accumulate as a log, and the author check reads the git email — an accountability aid for a cooperative collaborator, not an authentication boundary. Before editing the watch, readdocs/guides/push-watch.md: the record-before-assess ordering, thesincecursor's four rules, the per-cycle bounds and the prompt fence are all load-bearing and none are obvious from the code. - Tests:
python3 tests/test_watch.py— no pytest, no deps, stubs matrix-nio. Covers the push watch's policy (who gets reported, dedupe, the cursor, the path flag, prompt fencing); 17 mutations ofsrc/bot.pywere each confirmed to flip it to FAIL (2026-08-21). The orchestration insidewatch_repo, and everything else in this bot, is still verified by exercising it in a real review room. - 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. Same loop, per repo, runs the opt-in push watch:select_push_batches()holds the whole policy (whose commits, already-seen, cross-branch dedupe) as a pure function with no I/O — the part worth testing — whilewatch_repo()does the fetching andpost_push_notice()the notify-then-assess.config.example.toml— homeserver,[mac](ssh alias + the reused matrix-bridge wrapper paths),[gitea](api_base/owner/verify_tls/my_emails),[defaults], optional[repo.<name>]deploy + push-watch 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).tests/—test_watch.py+ a sanitized five-branch Gitea fixture (identities replaced, history overlap preserved because that overlap is what the dedupe is for).state/— gitignored runtime JSON (rooms.json: room map + agents + heads + thread roots + default branch, pluswatch: {since, seen[]}per push-watched room; the cursor's rules are in the push-watch guide).docs/guides/push-watch.md— the watch's internals (symlinked from.claude/rules/, scoped tosrc/bot.py, so it loads when you edit the bot).
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.design-checkerjoined the panel with the push watch — it audits against a repo's owndesign/DESIGN.md+ tokens, which is exactly the brand question ten31-brand's room asks. - Push watch: notify first, assess second (2026-08-21). What you need (someone pushed, and where) is plain Gitea data and a glob check, so it never depends on a model call that can be slow, fail or be wrong; the assessment threads under it. Beat: one composed message after the assessment (prettier, but a 480s budget then sits between a push and knowing about it).
- Watch scope: every branch except ones with an open PR. A PR already gets the full review treatment, so re-notifying its commits is noise. Beat: master-only (misses a side-branch push that never opens a PR).
- 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 was the ROADMAP fold-in — done 2026-08-21: both unbounded awaits are bounded and the poll
task's death now exits the process, so
restart: unless-stoppedhandles it.
Sovereignty
Untrusted input. A PR's title/description and a pushed commit's subjects, filenames and diff are
written by someone else and reach claude -p on the Mac, in my checkout, under my OAuth token. Both
prompts fence every such field (see the push-watch guide); the remaining half is a read-only tool
policy on that run, which needs a matrix-bridge change and is the top ROADMAP item.
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 on the Spark (e5a187b + docs, no redeploy pending), two review rooms, container healthy,
poll loop alive. python3 tests/test_watch.py green.
The push watch is live and verified in the real room (2026-08-21): a notice + assessment landed
for jonathan's 75cc63d, and my own commits pushed minutes later were recorded silently. On for
ten31-brand only. The Spark's config.toml holds the new my_emails + [repo.ten31-brand] keys
(gitignored, so they exist only there); pre-change copies of it and rooms.json are backed up
alongside them, dated 20260821.
- ten31-site — PR #5 "Update index.html" still open, reviewed 2026-06-30, waiting on a
merge/rejectin its thread. Push watch off here. - ten31-brand —
jonathanuses both routes: three PRs (#1–#3, all merged) and direct pushes tomaster(18 of the last 50 commits). Both are covered now. Every commit he has made touches onlydesign/deck/interactive/index.html, hence thatexpected_paths.
Next steps, in priority order:
- Fix the orphaned-thread prune (
src/bot.py): a PR resolved in Gitea's web UI loses its thread root from state without being redacted, soclearcan't reach it. No longer hypothetical — the threads for ten31-brand #2 and #3 are stranded in that room right now. /triagethe 3 untriaged inbox items tagged for this repo.- From ROADMAP: a read-only tool policy for the
claude -prun (needs a matrix-bridge change), and the Phase 3 verify — next time both rooms have open PRs, watch a cycle for one review head-of-line-blocking the other.