commit 6968e5d737f62aa0d961f5154e41cbd2d9000d48 Author: Keysat Date: Sun Jun 28 15:54:44 2026 -0500 Scaffold gitea-review-bot: thin matrix-nio PR-review bot (auto-map, threaded review, subagent panel, merge/reject/deploy) 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())