Files
gitea-review-bot/AGENTS.md
T

147 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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`.
- **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.
- **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.
- `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 `.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).
- `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 -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.
- **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 is the ROADMAP fold-in: bound the unbounded awaits and die if the poll task dies, so
`restart: unless-stopped` handles 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 a `merge`/`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 to `master`,
and the bot sees only pull requests. Collaborator `jonathan` has write access but is blocked from
pushing to `master`, so his changes must arrive as PRs; the owner's own pushes still bypass the bot.
**Next steps, in priority order:**
1. **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.
2. **`/triage` the 3 untriaged inbox items** tagged for this repo — adjudication never reads the inbox, so
their content is still unknown here.
3. 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.