Rooms mapped before the push watch existed carry no default_branch, so the "report a commit as being on master, not on some stale branch that also contains it" ordering never applied to them. Look it up once and keep it. known_hosts lives in the image, so it resets on every rebuild and the first ssh connection printed "Permanently added ... to the list of known hosts" straight into the room, prepended to the first assessment. Set LogLevel ERROR in the generated ssh config; real errors still come back. Corrects two claims in Current state that the API contradicts: the repo has had three PRs, not zero, and their review threads for #2 and #3 are orphaned in the room right now.
233 lines
18 KiB
Markdown
233 lines
18 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. 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; these repos' commits aren't linked to Gitea accounts,
|
||
the API returns `author: null`, so the **git email is the only identity that works**). Optional
|
||
`[repo.<name>].expected_paths` globs mark the collaborator's expected territory; files outside it
|
||
are flagged in the notice (deterministically, not by the model) and called out to the assessor.
|
||
Shape: **notify first, assess second** — the notice posts immediately, the commits are recorded in
|
||
`state/` *before* the assessment starts, and the assessment lands as a threaded reply. Scope is
|
||
every branch *except* branches with an open PR; the default branch is walked first, so a commit
|
||
reachable from several branches is reported as being on `master` rather than on some stale branch.
|
||
On first run the watch arms at "now" (it never replays history into the room); to force a test,
|
||
backdate `watch.since` in `state/rooms.json`.
|
||
- **Why the ordering:** recording before the assessment is what stops a crash, a restart or a send
|
||
error in those minutes from reposting the whole notice and re-running the assessment. The cost is
|
||
the opposite failure — a crash mid-assessment leaves a bare "_Assessing…_" in the room with no
|
||
retry. Preferred deliberately: a missing assessment is visible, a duplicate one is noise.
|
||
- **What it says out loud rather than logging:** more than `WATCH_MAX_PAGES × WATCH_COMMIT_LIMIT`
|
||
(150) commits arriving at once, and a commit whose file list Gitea didn't return (the path check
|
||
is then incomplete, and it must never print the all-clear).
|
||
- **Bounds:** ≤`WATCH_MAX_BATCHES` (2) assessments per repo per cycle, each ≤`[defaults]
|
||
watch_timeout_seconds` (480s; a real two-commit run with one panel subagent measured 173s), so a
|
||
busy push can hold the other rooms' polling for ~16 min worst case — the head-of-line cost ROADMAP
|
||
Phase 3 tracks, now bounded rather than open-ended. Held-over batches keep their commits out of
|
||
`seen` and pin the `since` cursor, so they go out next cycle; after `WATCH_GIVEUP_HOURS` (48) the
|
||
bot stops pinning and logs what it gave up on, so an unsendable batch can't freeze the watch.
|
||
- **Merging a PR banks its commits** (`remember_pr_commits`, including the merge commit Gitea
|
||
creates), whether it was merged from the thread or in Gitea's web UI — otherwise those commits
|
||
land on `master` and read as a fresh push of work the review already covered.
|
||
- **What it is not:** the author check reads the git email, which anyone can set. It is an
|
||
accountability aid for a cooperative collaborator, not an authentication boundary. And push
|
||
notices are *not* redactable — `clear` only reaches PR threads, so they accumulate as a log.
|
||
- **Tests:** `python3 tests/test_watch.py` — no pytest, no deps, stubs matrix-nio. Covers the push
|
||
watch's *policy*: who gets reported, cross-branch dedupe, the `since` cursor, the path flag, and
|
||
that every attacker-authored field lands inside the prompt's fence. 17 mutations of `src/bot.py`
|
||
were each confirmed to flip it to FAIL (2026-08-21). What it does **not** cover is the orchestration
|
||
inside `watch_repo` — the record-before-assess ordering, the per-cycle cap, held batches — because
|
||
that lives in closures over the live clients; those are verified by exercising the bot in a real
|
||
room, which is still how everything else here is verified.
|
||
- **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, plus
|
||
`watch: {since, seen[]}` per push-watched room — `since` is the Gitea `since` cursor, trailing the
|
||
newest commit by 24h so a committer's clock skew can't hide a push, and clamped to now so a
|
||
future-dated commit can't push the cursor into the future and silence the watch; `seen` is the last
|
||
500 SHAs accounted for).
|
||
|
||
## 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). The notice is a plain Gitea-derived
|
||
message posted before any `claude -p` runs, and the path flag ("outside the expected area") is
|
||
computed from globs, not asked of the model — so the thing you actually need (that someone pushed,
|
||
and where) never depends on a model call that can time out, fail, or be wrong. The assessment is a
|
||
threaded reply under it. Beat: one composed message after the assessment (prettier, but a 10-minute
|
||
`claude -p` budget 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 go to `claude -p` running on the Mac, in the owner's checkout, under the
|
||
owner's OAuth token. Both prompts put *every* such field inside one `UNTRUSTED DATA <nonce>` fence
|
||
with a per-run random tag, so a forged terminator can't close it, and instruct the model to report
|
||
rather than obey anything inside. That is the cheap half; the tool-policy half (an explicit read-only
|
||
`--allowedTools` on that run) 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 and in steady use**, serving **two** review rooms. Verified 2026-08-18: the container had been
|
||
up since 08-13 with zero restarts, and the poll loop is genuinely alive (two Gitea connections exactly
|
||
60s apart — see the liveness recipe in Commands).
|
||
|
||
**2026-08-21 — the collaborator push watch shipped.** `master` now carries code the Spark hasn't got
|
||
yet, so **a redeploy is pending** (and the Spark's `config.toml` needs the new `[gitea] my_emails` +
|
||
`[repo.ten31-brand]` keys, which are gitignored and live only there). Two independent reviewer passes
|
||
ran over it; the first found the batch being recorded only *after* its assessment (a crash would have
|
||
replayed the whole notice) and an all-clear line printable from a missing file list, the second found
|
||
untrusted PR/commit metadata sitting above the prompt's fence. All fixed, each with a mutation that
|
||
reproduces it.
|
||
|
||
- **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 a `merge`/`reject`. Push watch is **off** here.
|
||
- **ten31-brand** — onboarded 2026-08-18 to watch the interactive deck
|
||
(`design/deck/interactive/index.html`). Collaborator `jonathan` uses **both** routes, so both are
|
||
now covered: three PRs (#1–#3, all his, all merged 08-19/08-20, all reviewed by the bot) **and**
|
||
direct pushes to `master` — 18 of the last 50 commits there are his
|
||
(`Jonathan Kirkwood <jonathan@ten31.vc>`, most recently 2026-08-21). Two earlier notes here were
|
||
wrong and are corrected: he is *not* blocked from pushing to `master`, and the repo has *not* had
|
||
zero PRs. Every commit he has made touches exactly one file,
|
||
`design/deck/interactive/index.html`, which is why `expected_paths` is that subtree.
|
||
The review threads for #2 and #3 are **orphaned in the room right now** — merged outside the bot,
|
||
so the stale prune dropped their roots without redacting. That is Next step 2 below, no longer
|
||
hypothetical.
|
||
|
||
**Next steps, in priority order:**
|
||
1. **Deploy the push watch and watch one real notice land** (see the pending redeploy above).
|
||
2. **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 and `clear` can no longer reach it. More likely to bite now with two rooms.
|
||
3. **`/triage` the 3 untriaged inbox items** tagged for this repo — adjudication never reads the inbox, so
|
||
their content is still unknown here.
|
||
4. 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.
|