Scaffold gitea-review-bot: thin matrix-nio PR-review bot (auto-map, threaded review, subagent panel, merge/reject/deploy)

This commit is contained in:
Keysat
2026-06-28 15:54:44 -05:00
commit 6968e5d737
12 changed files with 888 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# gitea-review-bot credentials — COPY to .env (gitignored). Never commit real values.
# The real .env lives next to the deployment on the Spark.
MATRIX_HOMESERVER=https://<your-synapse-host>
MATRIX_USER=@reviewer:<your-domain>
MATRIX_DEVICE_ID=gitea-review-bot
MATRIX_ACCESS_TOKEN=
# Gitea access token with Read+Write on the repos it reviews (needed to merge/close PRs).
# Gitea → Settings → Applications → Generate Token, scope: repository = Read and Write.
GITEA_TOKEN=
+26
View File
@@ -0,0 +1,26 @@
# Secrets & local env
.env
.env.*
!.env.example
# Local deployment config + runtime state (per-deployment; never committed)
config.toml
state/
# Claude Code — deny by default, allow-list shared wiring.
# .claude/ also accumulates worktrees, editor configs, and OS cruft; commit
# only the shared parts so new local scratch (or a stray secret) stays out.
.claude/*
!.claude/rules/
!.claude/agents/
!.claude/commands/
!.claude/skills/
!.claude/settings.json
# Python
__pycache__/
*.pyc
.venv/
# OS cruft
.DS_Store
+91
View File
@@ -0,0 +1,91 @@
# 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 a Spark Control
Update tile, once added — captured in the inbox). `config.toml` is gitignored — refresh it on the
Spark separately (scp) like matrix-bridge.
- **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`.
- **In a review room:** `merge` / `reject` (inside a PR's thread, or `merge <n>` by number), `yes`/`no`
to confirm a merge, `agents …` to toggle the subagent panel.
## Layout
- `src/bot.py` — the bot: matrix-nio sync; auto-join + in-room auto-map (room→repo by name); a Gitea
poll loop per mapped repo; threaded `claude -p` review (subagent panel); merge/reject/deploy
in-thread; whole-thread redaction on resolve (server-enumerated, restart-proof).
- `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).
- `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.
## 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
**Scaffolded + initial bot built 2026-06-28.** First milestone: invite the bot to a room named
`<repo>` → it auto-joins, maps to `grant/<repo>`, and within ~60s of a new PR posts a threaded
`claude -p` review; `merge` + `yes` in that thread merges it on Gitea and redacts the thread. Core
flow ported from matrix-bridge (D15D19), generalized to multi-repo + the subagent panel.
**Not yet run** — needs the bot's `.env` (Matrix creds + `GITEA_TOKEN`) + `config.toml` on the
Spark, then `docker compose up -d --build`; then onboard the first repo and validate the panel +
in-chat agent toggles. A Spark Control tile is captured in the cross-project inbox.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
# gitea-review-bot — Spark container. Generic image; secrets/config via read-only mounts at runtime.
# Host networking (docker-compose) so it reaches Synapse (TLS), Gitea (LAN), and the Mac (SSH).
FROM python:3.12-slim
# openssh-client: the bot shells out to `ssh mac-bridge ...` to run the review + deploy on the Mac.
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# .env and config.toml arrive via read-only mounts at runtime (never baked).
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python", "-u", "src/bot.py"]
+22
View File
@@ -0,0 +1,22 @@
# gitea-review-bot
A thin Matrix bot that reviews and merges your **Gitea** pull requests, one review room per repo.
Separate from `matrix-bridge` (which runs interactive Claude Code sessions) — this bot's only job
is PR review.
## How it works
1. Create a Matrix room named like a repo and invite the bot. It auto-joins and maps the room to
`<owner>/<repo>` (and `~/Projects/<repo>` on the Mac), then posts an onboarding message.
2. It polls that repo's open PRs and posts each as a **thread**: a headless `claude -p` review (with
an optional subagent panel) run on the Mac over SSH.
3. In the thread: `merge` (then `yes`) or `reject`. On a clean merge it can auto-deploy, then
redacts the thread so the room shows only open PRs.
Pick which review agents run per room in chat: `agents +reviewer +security -adjudicator`.
## Run (on the Spark)
1. Copy `config.example.toml``config.toml` and `.env.example``.env`; fill them in (Matrix
creds, `GITEA_TOKEN`, the `[mac]` SSH alias + reused matrix-bridge wrapper paths, `[gitea]`).
2. `docker compose up -d --build`. Logs: `docker compose logs -f`.
See `AGENTS.md` for the full design, placement, and decisions.
+27
View File
@@ -0,0 +1,27 @@
# gitea-review-bot — ROADMAP
Phases beyond the first milestone (threaded single-flow review + merge/reject/deploy; see AGENTS.md
`## Current state`).
## Phase 2 — Subagent panel, proven
- The lead `claude -p` reliably spawns the enabled subagents (reviewer / adjudicator /
security-auditor) and presents each opinion + its own recommendation. **Exit:** a real PR review
shows up to 3 distinct, labeled opinions + the lead's call; flaky headless subagent spawning
falsifies it → fall back to bot-orchestrated separate `claude -p` runs (the rejected alternative).
- In-chat `agents +/-` toggles change which subagents run on the next review, per room (persisted).
## Phase 3 — Multi-repo at scale
- Onboard ≥3 repos to their own rooms; confirm independent polling, no cross-talk. **Exit:** three
repos reviewed from three rooms in one run with no interleaving.
- Per-repo deploy (`[repo.<name>].deploy_on_merge`) exercised on a non-static repo (not a Pages site).
## Phase 4 — Spark Control tile
- Status badge + Update/Restart/Stop-Start/Logs, mirroring matrix-bridge's D10 (captured in the
cross-project inbox as a spark-control item).
## Deferred / non-goals
- Not a Claude-session bot (that's matrix-bridge); not Maubot (revisit at ~6+ bots or web-UI mgmt);
no GitHub/non-Gitea; no Gitea account/permission management; E2EE deferred (private LAN transport).
- A shared "bot kit" library (extract the common matrix-nio + SSH plumbing across the three bots)
once duplication bites — the lighter step before ever considering Maubot.
- Poll → webhook upgrade if 60s latency ever matters.
+30
View File
@@ -0,0 +1,30 @@
# gitea-review-bot — deployment config (EXAMPLE). Copy to config.toml (gitignored) and fill in.
# Credentials live in the gitignored .env, NOT here.
[homeserver]
url = "https://<your-synapse-host>"
user = "@reviewer:<your-domain>" # the bot's OWN Matrix account (not matrix-bridge's @agent)
# How the bot reaches the Mac (reuses matrix-bridge's Spark→Mac seam + wrappers).
[mac]
ssh_alias = "mac-bridge"
# The review runs `claude -p` in the repo — matrix-bridge's ask-claude.sh does exactly that.
review_launcher = "/Users/macpro/Projects/matrix-bridge/scripts/ask-claude.sh"
# Post-merge publish (per-repo, opt-in) — matrix-bridge's deploy-site.sh (ff-pull master + deploy).
deploy_launcher = "/Users/macpro/Projects/matrix-bridge/scripts/deploy-site.sh"
hostname = "10.59.211.5" # the Mac over WireGuard (container generates ~/.ssh/config)
user = "macpro"
[gitea]
api_base = "https://<gitea-host-or-IP>:<port>/api/v1" # use the LAN IP if only a .local name exists
# (a slim container can't resolve mDNS .local)
owner = "grant" # repos are <owner>/<roomname>
verify_tls = false # StartOS Gitea serves a self-signed cert on the LAN
[defaults]
poll_interval_seconds = 60
review_timeout_seconds = 600 # a `claude -p` review (with subagents) can take minutes
# Optional per-repo overrides (auto-deploy is OFF unless set here). Keyed by repo/room name.
# [repo.ten31-site]
# deploy_on_merge = true
+18
View File
@@ -0,0 +1,18 @@
# gitea-review-bot — Spark deployment. `docker compose up -d` runs it detached; restart survives
# reboots. Host networking reaches Synapse (TLS), Gitea (LAN), and the Mac (WireGuard SSH alias).
services:
bot:
build: .
image: gitea-review-bot
container_name: gitea-review-bot
network_mode: host
restart: unless-stopped
volumes:
- ./.env:/app/.env:ro
- ./config.toml:/app/config.toml:ro
# Writable state (room→repo map, enabled agents, reviewed heads, thread roots). Survives
# restarts/rebuilds; gitignored.
- ./state:/app/state:rw
# Reuse the SAME Spark→Mac key matrix-bridge uses (chmod 600 on the host).
# Override the host path with GRB_SSH_KEY_HOST if the key lives elsewhere.
- ${GRB_SSH_KEY_HOST:-/home/modelo/.ssh/id_ed25519}:/root/.ssh/id_ed25519:ro
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# gitea-review-bot container entrypoint — the container's "environment seam".
#
# Generates ~/.ssh/config for the Mac alias from config.toml's [mac] section, then execs the bot.
# SSH-client wiring lives here, not in bot.py (same split matrix-bridge uses). On the Spark host the
# bot would use modelo's existing ~/.ssh/config; in the container we recreate just the one alias.
set -e
SSH_DIR="$HOME/.ssh"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
GRB_SSH_KEY="${GRB_SSH_KEY:-$SSH_DIR/id_ed25519}" \
SSH_CONFIG="$SSH_DIR/config" \
KNOWN_HOSTS="$SSH_DIR/known_hosts" \
python - <<'PY'
import os, tomllib
with open("/app/config.toml", "rb") as f:
mac = tomllib.load(f)["mac"]
config = f"""Host {mac.get('ssh_alias', 'mac-bridge')}
HostName {mac['hostname']}
User {mac['user']}
IdentityFile {os.environ['GRB_SSH_KEY']}
IdentitiesOnly yes
StrictHostKeyChecking accept-new
UserKnownHostsFile {os.environ['KNOWN_HOSTS']}
"""
with open(os.environ['SSH_CONFIG'], "w") as f:
f.write(config)
PY
chmod 600 "$SSH_DIR/config"
exec "$@"
+2
View File
@@ -0,0 +1,2 @@
matrix-nio>=0.24
tomli>=2.0; python_version < "3.11"
+606
View File
@@ -0,0 +1,606 @@
#!/usr/bin/env python3
"""gitea-review-bot — per-repo Gitea PR review in Matrix.
A thin matrix-nio bot, separate from matrix-bridge. Invite it to a room → it auto-joins and maps the
room to a repo by name (room "foo" → Gitea <owner>/foo + ~/Projects/foo). For each mapped repo it
polls open PRs, runs a headless `claude -p` review on the Mac (an optional subagent panel), posts the
verdict as a Matrix thread per PR, and lets you merge/reject from the thread; a clean resolve redacts
the thread. Runs on the Spark; the review + any deploy run on the Mac over SSH, reusing matrix-bridge's
wrappers. Config: ../config.toml Creds: ../.env State: state/rooms.json
"""
import asyncio
import difflib
import json
import os
import shlex
import ssl
import urllib.error
import urllib.parse
import urllib.request
try:
import tomllib # py >= 3.11
except ModuleNotFoundError:
import tomli as tomllib # py < 3.11
from nio import AsyncClient, InviteMemberEvent, MatrixRoom, RoomMessageText
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REVIEW_TIMEOUT = 600 # seconds for a `claude -p` review (subagent panel can take minutes)
DEPLOY_TIMEOUT = 180 # seconds for a post-merge deploy
SETUP_TIMEOUT = 30 # seconds for setup SSH probes (ls ~/Projects, test -d)
HTTP_TIMEOUT = 30 # seconds per Gitea / Matrix API request
GITEA_DIFF_LIMIT = 40000 # max diff chars embedded in the review prompt (crosses SSH as one arg)
MAX_MSG_CHARS = 30000 # split long messages under Matrix's ~64KB event cap
# Subagents the panel can run. Aliases map loose user input to the canonical name.
PANEL_AGENTS = ("reviewer", "adjudicator", "security-auditor")
AGENT_ALIASES = {"security": "security-auditor", "sec": "security-auditor",
"review": "reviewer", "adjudicate": "adjudicator", "adj": "adjudicator"}
AGENT_BRIEF = {
"reviewer": "an independent code review — does the code make sense? correctness, bugs, quality",
"adjudicator": "an adjudication — is this change necessary/relevant to include, or unnecessary?",
"security-auditor": "a security review of the diff — injection, secrets, auth, unsafe patterns",
}
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k] = v.strip().strip('"')
return env
def load_config(path):
with open(path, "rb") as f:
return tomllib.load(f)
def load_state(path):
try:
with open(path) as f:
return json.load(f)
except (FileNotFoundError, ValueError):
return {}
def save_state(path, state):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(state, f, indent=2)
os.replace(tmp, path)
def gitea_api(method, url, token, verify_tls, payload=None, accept="application/json"):
"""Blocking Gitea API request; returns (status|None, body_text). Run via asyncio.to_thread.
payload (dict) -> JSON body for POST/PATCH. verify_tls=False for a self-signed LAN Gitea."""
headers = {"Authorization": f"token {token}", "Accept": accept}
data = None
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
ctx = ssl.create_default_context()
if not verify_tls:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
return resp.status, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="replace")
except Exception as e:
return None, repr(e)
def gitea_error_message(body):
try:
return json.loads(body).get("message") or body[:200]
except (ValueError, AttributeError, TypeError):
return (body or "")[:200] or "(no detail)"
def thread_root_of(event):
relates = (event.source or {}).get("content", {}).get("m.relates_to") or {}
if relates.get("rel_type") == "m.thread":
return relates.get("event_id")
return None
def in_reply_to_id(event):
relates = (event.source or {}).get("content", {}).get("m.relates_to") or {}
return (relates.get("m.in_reply_to") or {}).get("event_id")
def message_text(event):
"""The user's typed text, with any rich-reply / thread quoted fallback stripped (a Matrix reply
prepends the quoted original as `>`-lines, so otherwise the first word parses as `>`)."""
body = event.body or ""
if in_reply_to_id(event):
lines = body.split("\n")
i = 0
while i < len(lines) and lines[i].startswith(">"):
i += 1
if i < len(lines) and lines[i].strip() == "":
i += 1
body = "\n".join(lines[i:])
return body.strip()
def split_message(text, limit=MAX_MSG_CHARS):
if len(text) <= limit:
return [text]
chunks, buf = [], ""
for line in text.splitlines(keepends=True):
while len(line) > limit:
if buf:
chunks.append(buf); buf = ""
chunks.append(line[:limit]); line = line[limit:]
if len(buf) + len(line) > limit:
chunks.append(buf); buf = ""
buf += line
if buf:
chunks.append(buf)
return chunks
def suggest_repo(room_name, candidates):
if not room_name or not candidates:
return None
target = room_name.strip().lstrip("#").lower()
exact = {c.lower(): c for c in candidates}
if target in exact:
return exact[target]
subs = [c for c in candidates if target in c.lower()]
if len(subs) == 1:
return subs[0]
close = difflib.get_close_matches(target, candidates, n=1, cutoff=0.6)
return close[0] if close else None
def build_review_prompt(label, pr, diff, truncated, agents):
"""One-shot `claude -p` prompt: review this PR's diff, return a Matrix-ready verdict. If `agents`
is non-empty the lead session also spawns those subagents (Task tool) and presents each verdict
plus its own overall recommendation (the subagent panel)."""
num = pr.get("number")
title = pr.get("title", "")
author = (pr.get("user") or {}).get("login", "?")
body = (pr.get("body") or "").strip() or "(no description)"
head_ref = (pr.get("head") or {}).get("ref", "?")
base_ref = (pr.get("base") or {}).get("ref", "?")
trunc = (f"\n\n[The diff was truncated to the first {GITEA_DIFF_LIMIT} characters — review what is "
"shown and say the review is partial.]" if truncated else "")
if agents:
panel = "\n".join(f" - **{a}** subagent: {AGENT_BRIEF[a]}" for a in agents)
panel_block = f"""
Then convene a review panel: use the Task tool to spawn each of these subagents independently, give \
each the PR metadata and the diff below, and reproduce each one's verdict verbatim under its own \
**heading**:
{panel}
Finally, weigh your own read and the panel and give an **OVERALL RECOMMENDATION** on the first line.
"""
else:
panel_block = ""
return f"""You are the lead reviewer for a proposed pull request to `{label}`. You are running \
inside a checkout of the base branch, so READ any file in this repo for context (and its \
AGENTS.md/CLAUDE.md for conventions). This is a READ-ONLY review — do not modify anything.
Pull request #{num}: "{title}"
Author: {author} Branch: {head_ref} -> {base_ref}
Description: {body}
Your ENTIRE stdout becomes one Matrix message read on a phone — output only the review, no preamble. \
Lead off with:
RECOMMENDATION: ACCEPT / REQUEST CHANGES / REJECT (first line — should this be merged into \
{base_ref} as-is? intent-aware: a work-in-progress / test / "do not merge" PR is REJECT even if clean)
Then: **What it changes** (2-5 bullets), **Concerns** (bugs, broken markup/links, design/convention \
violations, security, content — or "none significant"), **Notes** (optional).
{panel_block}{trunc}
--- BEGIN UNIFIED DIFF ---
{diff}
--- END UNIFIED DIFF ---
"""
async def main():
env = load_env(os.path.join(REPO_ROOT, ".env"))
cfg = load_config(os.path.join(REPO_ROOT, "config.toml"))
homeserver = env["MATRIX_HOMESERVER"]
user_id = env["MATRIX_USER"]
token = env["MATRIX_ACCESS_TOKEN"]
device_id = env.get("MATRIX_DEVICE_ID", "gitea-review-bot")
gitea_token = env.get("GITEA_TOKEN", "")
ssh_alias = os.environ.get("GRB_SSH_ALIAS") or cfg["mac"]["ssh_alias"]
review_launcher = cfg["mac"]["review_launcher"]
deploy_launcher = cfg["mac"].get("deploy_launcher")
mac_user = cfg["mac"]["user"]
projects_base = f"/Users/{mac_user}/Projects"
gitea_api_base = cfg["gitea"]["api_base"].rstrip("/")
gitea_owner = cfg["gitea"]["owner"]
gitea_verify = cfg["gitea"].get("verify_tls", True)
poll_interval = cfg.get("defaults", {}).get("poll_interval_seconds", 60)
review_timeout = cfg.get("defaults", {}).get("review_timeout_seconds", REVIEW_TIMEOUT)
repo_overrides = cfg.get("repo", {}) # {name: {deploy_on_merge: bool}}
state_dir = os.environ.get("GRB_STATE") or os.path.join(REPO_ROOT, "state")
rooms_path = os.path.join(state_dir, "rooms.json")
rooms = load_state(rooms_path) # room_id -> {repo,repo_dir,label,agents,heads,threads}
pending_setup = {} # room_id -> suggested folder (joined, not yet mapped)
pending_merge = {} # (room_id, pr_str) -> True : awaiting `yes` in the thread
client = AsyncClient(homeserver, user_id)
client.restore_login(user_id=user_id, device_id=device_id, access_token=token)
# ---- primitives -------------------------------------------------------------------------
async def say(room_id, text, thread_root=None):
content = {"msgtype": "m.text", "body": text}
if thread_root:
content["m.relates_to"] = {
"rel_type": "m.thread", "event_id": thread_root,
"is_falling_back": True, "m.in_reply_to": {"event_id": thread_root},
}
resp = await client.room_send(room_id, "m.room.message", content)
return getattr(resp, "event_id", None)
async def ssh_run(remote, timeout):
"""Run a command on the Mac over SSH; return (rc, combined_output). rc=None on timeout."""
proc = await asyncio.create_subprocess_exec(
"ssh", ssh_alias, remote,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill(); await proc.wait()
return None, f"timed out after {timeout}s"
return proc.returncode, out.decode(errors="replace").strip()
async def run_review(repo_dir, prompt):
remote = f"{shlex.quote(review_launcher)} {shlex.quote(repo_dir)} {shlex.quote(prompt)}"
return await ssh_run(remote, review_timeout)
async def run_deploy(repo_dir):
remote = f"{shlex.quote(deploy_launcher)} {shlex.quote(repo_dir)}"
return await ssh_run(remote, DEPLOY_TIMEOUT)
async def dir_exists(path):
rc, _ = await ssh_run(f"test -d {shlex.quote(path)}", SETUP_TIMEOUT)
return rc == 0
async def list_projects():
rc, out = await ssh_run(
f"find {shlex.quote(projects_base)} -maxdepth 1 -mindepth 1 -type d", SETUP_TIMEOUT)
if rc != 0 or not out:
return []
return sorted(os.path.basename(p) for p in out.splitlines() if p)
def matrix_get(path):
req = urllib.request.Request(homeserver.rstrip("/") + path,
headers={"Authorization": "Bearer " + token})
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
except Exception as e:
return None, repr(e)
def gitea_g(path, accept="application/json"):
return gitea_api("GET", f"{gitea_api_base}{path}", gitea_token, gitea_verify, accept=accept)
def gitea_w(method, path, payload):
return gitea_api(method, f"{gitea_api_base}{path}", gitea_token, gitea_verify, payload)
# ---- auto-join + in-room auto-map (mirrors matrix-bridge D14) ----------------------------
async def start_setup(rid, room_name):
pending_setup[rid] = None
candidates = await list_projects()
suggestion = suggest_repo(room_name, candidates)
pending_setup[rid] = suggestion
if suggestion:
await say(rid, f"👋 Joined. This looks like the review room for **{suggestion}** — reply "
f"`yes` to watch `{gitea_owner}/{suggestion}`, or send another repo name.")
else:
hint = f" (e.g. {', '.join(candidates[:6])})" if candidates else ""
await say(rid, f"👋 Joined, but I can't tell which repo this room is for. Reply with the "
f"repo name{hint}.")
async def finish_setup(rid, answer):
if rid in rooms:
return
tokens = answer.split()
suggestion = pending_setup.get(rid)
if tokens and tokens[0].lower() in ("yes", "y") and suggestion:
name = suggestion
elif tokens:
name = tokens[0].lstrip("#")
else:
await say(rid, "Didn't catch a repo name — reply with the repo this room should review.")
return
repo_dir = f"{projects_base}/{name}"
if not await dir_exists(repo_dir):
await say(rid, f"⚠️ No repo checkout at `{repo_dir}` on the Mac. Pick an existing one.")
return
status, _ = await asyncio.to_thread(lambda: gitea_g(f"/repos/{gitea_owner}/{name}"))
if status != 200:
await say(rid, f"⚠️ Gitea has no repo `{gitea_owner}/{name}` (HTTP {status}). Check the name.")
return
rooms[rid] = {"repo": f"{gitea_owner}/{name}", "repo_dir": repo_dir, "label": name,
"agents": [], "heads": {}, "threads": {}}
pending_setup.pop(rid, None)
save_state(rooms_path, rooms)
print(f"mapped {rid} -> {gitea_owner}/{name}", flush=True)
await say(rid,
f"✅ This room now reviews **{gitea_owner}/{name}**. New PRs will appear here as "
f"threads — `merge`/`reject` inside a thread (`yes` to confirm a merge).\n\n"
f"Review panel (extra opinions from the lead `claude -p` review): "
f"{', '.join(PANEL_AGENTS)}. Currently **off** (lead review only). Turn some on with "
f"`agents +reviewer +security`, off with `agents -adjudicator`.")
async def handle_agents(rid, parts):
room = rooms[rid]
enabled = set(room.get("agents", []))
changed = False
for tok in parts[1:]:
sign = "+"
if tok and tok[0] in "+-":
sign, tok = tok[0], tok[1:]
name = AGENT_ALIASES.get(tok.lower(), tok.lower())
if name not in PANEL_AGENTS:
await say(rid, f"Unknown agent `{tok}`. Options: {', '.join(PANEL_AGENTS)}.")
return
if sign == "-":
enabled.discard(name); changed = True
else:
enabled.add(name); changed = True
if changed:
room["agents"] = [a for a in PANEL_AGENTS if a in enabled] # canonical order
save_state(rooms_path, rooms)
cur = ", ".join(room["agents"]) or "none (lead review only)"
await say(rid, f"🧪 Review panel for **{room['label']}**: {cur}.")
# ---- review + resolve (per mapped room) -------------------------------------------------
async def redact_thread(rid, num):
"""Redact a resolved PR's whole thread (root + replies), enumerating members from the server
so it works even after a restart. Needs redact power in the room."""
room = rooms.get(rid, {})
root = room.get("threads", {}).pop(str(num), None)
save_state(rooms_path, rooms)
if not root:
return
eids, frm = [root], None
rq = urllib.parse.quote(rid, safe="")
rootq = urllib.parse.quote(root, safe="")
for _ in range(20):
q = "?limit=100" + (f"&from={urllib.parse.quote(frm)}" if frm else "")
status, data = await asyncio.to_thread(
matrix_get, f"/_matrix/client/v1/rooms/{rq}/relations/{rootq}/m.thread{q}")
if status != 200 or not isinstance(data, dict):
break
eids += [ev["event_id"] for ev in data.get("chunk", []) if ev.get("event_id")]
frm = data.get("next_batch")
if not frm:
break
for eid in eids:
try:
await client.room_redact(rid, eid)
except Exception as e:
print(f"redact {eid} failed: {e!r}", flush=True)
print(f"redacted {room.get('label')} PR #{num} thread ({len(eids)} events)", flush=True)
async def review_pr(rid, pr):
room = rooms[rid]
num = pr["number"]
title = pr.get("title", "")
author = (pr.get("user") or {}).get("login", "?")
url = pr.get("html_url", "")
repo = room["repo"]
dstatus, diff = await asyncio.to_thread(
lambda: gitea_g(f"/repos/{repo}/pulls/{num}.diff", accept="text/plain"))
if dstatus != 200 or not (diff or "").strip():
print(f"{room['label']} PR #{num} diff HTTP {dstatus} — retrying", flush=True)
return False
truncated = len(diff) > GITEA_DIFF_LIMIT
if truncated:
diff = diff[:GITEA_DIFF_LIMIT]
prompt = build_review_prompt(room["label"], pr, diff, truncated, room.get("agents", []))
rc, out = await run_review(room["repo_dir"], prompt)
if rc != 0:
print(f"{room['label']} PR #{num} review FAILED rc={rc}: {(out or '')[:300]}", flush=True)
await say(rid, f"⚠️ Review failed for PR #{num}{title}” (rc={rc}): {(out or 'no output')[:500]}")
return True
chunks = split_message(f"📋 PR #{num}{title}” by {author}\n{url}\n\n"
+ (out or "(claude returned no output)"))
root = await say(rid, chunks[0])
if root:
room["threads"][str(num)] = root
save_state(rooms_path, rooms)
for chunk in chunks[1:]:
await say(rid, chunk, thread_root=root)
else:
for chunk in chunks[1:]:
await say(rid, chunk)
print(f"reviewed {room['label']} PR #{num} ({len(out)} chars)", flush=True)
return True
async def deploy_after_merge(rid, num):
room = rooms[rid]
override = repo_overrides.get(room["label"], {})
if not (override.get("deploy_on_merge") and deploy_launcher):
return True
root = room.get("threads", {}).get(str(num))
await say(rid, "🚀 Publishing the merged change to the live site…", root)
rc, out = await run_deploy(room["repo_dir"])
if rc == 0:
await say(rid, "✅ Live site updated.", root)
return True
tail = (out or "no output").splitlines()[-1] if out else "no output"
print(f"{room['label']} deploy FAILED rc={rc}: {(out or '')[:400]}", flush=True)
await say(rid, f"⚠️ Merged, but auto-deploy didn't run (rc={rc}): {tail[:300]} "
f"— deploy it manually.", root)
return False
async def do_merge(rid, num):
room = rooms[rid]
root = room.get("threads", {}).get(str(num))
status, body = await asyncio.to_thread(
lambda: gitea_w("POST", f"/repos/{room['repo']}/pulls/{num}/merge",
{"Do": "merge", "force_merge": True}))
if status is not None and 200 <= status < 300:
print(f"merged {room['label']} PR #{num}", flush=True)
if await deploy_after_merge(rid, num):
await redact_thread(rid, num)
else:
detail = gitea_error_message(body) if status else body
await say(rid, f"⚠️ merge PR #{num} failed (HTTP {status}): {detail}", root)
async def do_reject(rid, num):
room = rooms[rid]
root = room.get("threads", {}).get(str(num))
status, body = await asyncio.to_thread(
lambda: gitea_w("PATCH", f"/repos/{room['repo']}/pulls/{num}", {"state": "closed"}))
if status is not None and 200 <= status < 300:
print(f"closed {room['label']} PR #{num}", flush=True)
await redact_thread(rid, num)
else:
detail = gitea_error_message(body) if status else body
await say(rid, f"⚠️ reject PR #{num} failed (HTTP {status}): {detail}", root)
def pr_for_thread(rid, event):
root = thread_root_of(event)
if not root:
return None
for num, r in rooms.get(rid, {}).get("threads", {}).items():
if r == root:
return num
return None
async def handle_command(rid, event):
text = message_text(event)
parts = text.split()
if not parts:
return
cmd = parts[0].lower()
if cmd == "agents":
await handle_agents(rid, parts)
return
thread_pr = pr_for_thread(rid, event)
if cmd in ("yes", "y") and thread_pr is not None and (rid, thread_pr) in pending_merge:
pending_merge.pop((rid, thread_pr), None)
await do_merge(rid, int(thread_pr))
return
if cmd in ("no", "cancel") and thread_pr is not None and (rid, thread_pr) in pending_merge:
pending_merge.pop((rid, thread_pr), None)
await say(rid, f"❌ Merge of PR #{thread_pr} cancelled.",
rooms[rid]["threads"].get(thread_pr))
return
if cmd not in ("merge", "close", "reject"):
return
if thread_pr is not None:
num = int(thread_pr)
elif len(parts) >= 2:
try:
num = int(parts[1].lstrip("#"))
except ValueError:
await say(rid, f"`{parts[1]}` isn't a PR number. Try `{cmd} 1`.")
return
else:
await say(rid, f"Type `{cmd}` inside a PR's thread, or give the number: `{cmd} 1`.")
return
root = rooms[rid].get("threads", {}).get(str(num))
if cmd == "merge":
pending_merge[(rid, str(num))] = True
await say(rid, f"⚠️ Reply `yes` in this thread to merge PR #{num} (or `no` to cancel).", root)
else:
await do_reject(rid, num)
# ---- event callbacks --------------------------------------------------------------------
async def on_message(room: MatrixRoom, event: RoomMessageText):
if event.sender == user_id:
return
rid = room.room_id
if rid in rooms:
await handle_command(rid, event)
elif rid in pending_setup:
await finish_setup(rid, message_text(event))
else:
await start_setup(rid, room.display_name)
async def on_invite(room: MatrixRoom, event: InviteMemberEvent):
if event.state_key != user_id:
return
rid = room.room_id
resp = await client.join(rid)
if not getattr(resp, "room_id", None):
print(f"FAILED to auto-join {rid}: {resp}", flush=True)
return
print(f"auto-joined {rid} (invited by {event.sender})", flush=True)
if rid not in rooms:
await start_setup(rid, room.display_name)
# ---- Gitea poll loop over all mapped repos ----------------------------------------------
async def poll_loop():
print(f"gitea-review-bot: polling {len(rooms)} mapped repo(s) every {poll_interval}s", flush=True)
while True:
for rid in list(rooms.keys()):
room = rooms.get(rid)
if not room:
continue
try:
status, body = await asyncio.to_thread(
lambda: gitea_g(f"/repos/{room['repo']}/pulls?state=open&sort=recentupdate&limit=50"))
if status != 200:
print(f"{room['label']} list PRs HTTP {status}: {str(body)[:200]}", flush=True)
continue
open_prs = json.loads(body)
open_nums = {str(p["number"]) for p in open_prs}
for pr in open_prs:
num = str(pr["number"])
head = (pr.get("head") or {}).get("sha", "")
if room["heads"].get(num) == head:
continue
if await review_pr(rid, pr):
room["heads"][num] = head
save_state(rooms_path, rooms)
stale = [n for n in room["heads"] if n not in open_nums]
if stale:
for n in stale:
room["heads"].pop(n, None)
room.get("threads", {}).pop(n, None)
save_state(rooms_path, rooms)
except Exception as e:
print(f"poll error ({room.get('label')}): {e!r}", flush=True)
await asyncio.sleep(poll_interval)
client.add_event_callback(on_invite, InviteMemberEvent)
print("priming sync (skipping backlog)...", flush=True)
await client.sync(timeout=30000, full_state=False)
client.add_event_callback(on_message, RoomMessageText)
who = await client.whoami()
print(f"listening as {who.user_id}; {len(rooms)} mapped review room(s)", flush=True)
poll_task = asyncio.create_task(poll_loop())
try:
await client.sync_forever(timeout=30000)
finally:
poll_task.cancel()
await client.close()
if __name__ == "__main__":
asyncio.run(main())