From 6968e5d737f62aa0d961f5154e41cbd2d9000d48 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sun, 28 Jun 2026 15:54:44 -0500 Subject: [PATCH] Scaffold gitea-review-bot: thin matrix-nio PR-review bot (auto-map, threaded review, subagent panel, merge/reject/deploy) --- .env.example | 11 + .gitignore | 26 ++ AGENTS.md | 91 +++++++ CLAUDE.md | 1 + Dockerfile | 21 ++ README.md | 22 ++ ROADMAP.md | 27 ++ config.example.toml | 30 +++ docker-compose.yml | 18 ++ docker-entrypoint.sh | 33 +++ requirements.txt | 2 + src/bot.py | 606 +++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 888 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 config.example.toml create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 requirements.txt create mode 100644 src/bot.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8b36b1 --- /dev/null +++ b/.env.example @@ -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:// +MATRIX_USER=@reviewer: +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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e863fb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dcfab9 --- /dev/null +++ b/AGENTS.md @@ -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 `/` (+ `~/Projects/` 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 ` 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.]` 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 `` → Gitea `/` + `~/Projects/`; 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 +`` → it auto-joins, maps to `grant/`, 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 (D15–D19), 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b20beb1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d33187 --- /dev/null +++ b/README.md @@ -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 + `/` (and `~/Projects/` 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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f4638dc --- /dev/null +++ b/ROADMAP.md @@ -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.].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. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..17f8d05 --- /dev/null +++ b/config.example.toml @@ -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://" +user = "@reviewer:" # 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://:/api/v1" # use the LAN IP if only a .local name exists + # (a slim container can't resolve mDNS .local) +owner = "grant" # repos are / +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b2eda6 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..a456f66 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ad1532 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +matrix-nio>=0.24 +tomli>=2.0; python_version < "3.11" diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..d40c118 --- /dev/null +++ b/src/bot.py @@ -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 /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())