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.
194 lines
15 KiB
Markdown
194 lines
15 KiB
Markdown
# 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.md` exists, 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-write `state/`). Logs: `docker compose logs -f`. `known_hosts` lives inside the image, so it
|
||
resets on every rebuild — the ssh config sets `LogLevel ERROR` so 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-bot` is a Gitea clone tracking `master`; 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 unless `GITEA_REVIEW_USER`
|
||
is set in its Settings). `config.toml` is gitignored — refresh it on the
|
||
Spark separately (scp) like matrix-bridge. Reach the Spark from the Mac as **`ssh modelo32`** (the
|
||
**NVIDIA Sync** alias — host `spark-32d0`, user `modelo`, key-based; injected into `~/.ssh/config`
|
||
via an `Include`. `modelo27`/`spark-27ea` is a second Spark on another LAN). `reset --hard` is safe:
|
||
`.env`/`config.toml`/`state/` are gitignored.
|
||
**Deploy-key gotcha:** each repo the Spark clones needs its *own* Gitea deploy key plus its own
|
||
`Host` alias — Gitea refuses to reuse one SSH key across two repos' deploy keys, and the default
|
||
`immense-voyage.local` block 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-reads `list(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`/`no` to confirm a merge, `agents …` to toggle the subagent panel. `clear` (alias `resolve`)
|
||
redacts an orphaned thread without touching the PR.
|
||
- **Merging under branch protection:** these repos protect `master` with push + merge whitelists
|
||
(`grant` only) and `required_approvals = 1`. The bot never posts an approving *review* to Gitea, so
|
||
a collaborator's PR page shows as blocked pending approval — the in-thread `merge` still lands it,
|
||
because the bot merges with `force_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 -p` assessment of what they did as a threaded reply. Turn on with
|
||
`[repo.<name>].watch_pushes = true` plus `[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_paths` globs 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 (`clear` only 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, read `docs/guides/push-watch.md`:** the
|
||
record-before-assess ordering, the `since` cursor'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 of `src/bot.py` were each confirmed to flip it to FAIL (2026-08-21). The orchestration
|
||
inside `watch_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` — `EE73` is Gitea's port 61043 in hex, and **two hits ~60s apart =
|
||
healthy**. Why the fast sampling: the poll socket lives milliseconds and leaves no `TIME_WAIT` (Gitea
|
||
closes first), so 1–2s polling misses it entirely and reads as a false wedge. Also: the container has
|
||
no `ps`, `sudo` on the Spark wants a password (no `tcpdump`), and host networking means the container's
|
||
`/proc/1/net/tcp` *is* 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); threaded `claude -p` review (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); `clear`
|
||
redacts 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 — while `watch_repo()` does the fetching and
|
||
`post_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 `.env` gitignored). The live token is scoped
|
||
`write:repository` only: repo/PR calls work, but `/user` and anything needing `read:user` 403s.
|
||
- `Dockerfile` · `docker-compose.yml` · `docker-entrypoint.sh` — the Spark container (generic image;
|
||
secrets/config via read-only mounts; entrypoint writes `~/.ssh/config` for 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, plus `watch: {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 to
|
||
`src/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 -p` session spawns subagents and presents each
|
||
output + its own overall recommendation. Beat: bot-orchestrated separate `claude -p` runs (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-checker` joined the panel with the push watch — it audits against a repo's own
|
||
`design/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 to
|
||
`state/` (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-stopped` handles 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`/`reject` in its thread. Push watch off here.
|
||
- **ten31-brand** — `jonathan` uses **both** routes: three PRs (#1–#3, all merged) *and* direct
|
||
pushes to `master` (18 of the last 50 commits). Both are covered now. Every commit he has made
|
||
touches only `design/deck/interactive/index.html`, hence that `expected_paths`.
|
||
|
||
**Next steps, in priority order:**
|
||
1. **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, so `clear` can't reach it. No longer hypothetical — the
|
||
threads for ten31-brand #2 and #3 are stranded in that room right now.
|
||
2. **`/triage` the 3 untriaged inbox items** tagged for this repo.
|
||
3. From ROADMAP: a read-only tool policy for the `claude -p` run (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.
|