# Herald Herald is a Gitea bot that performs automated AI-powered code reviews on pull requests using [OpenRouter](https://openrouter.ai/). ## Features - Listens for Gitea webhook events and triggers code reviews on pull request comments - Streams reviews back to Gitea as PR comments - Concurrent review processing with configurable parallelism - Graceful shutdown — in-progress reviews finish before the process exits - Error tracking via Sentry - Prometheus metrics endpoint for monitoring - Tiny memory footprint (~4MB) thanks to Rust ## Installation **Requirements:** Rust toolchain ([rustup.rs](https://rustup.rs)) ```sh cargo build --release ./target/release/herald ``` Herald reads its configuration from environment variables (a `.env` file is supported): | Variable | Description | |---|---| | `HTTP_PORT` | Port to listen on | | `BOT_NAME` | The bot's Gitea username (used to detect mentions) | | `WEBHOOK_SIG_HEADER_SECRET` | Gitea webhook secret for signature verification | | `OPEN_ROUTER_API_KEY` | OpenRouter API key | | `OPEN_ROUTER_MODEL` | Model to use (e.g. `deepseek/deepseek-v4-flash`) | | `OPEN_ROUTER_TIMEOUT` | OpenRouter request timeout in seconds | | `BOT_MAX_CONCURRENT` | Maximum number of concurrent reviews | | `GITEA_URL` | Base URL of your Gitea instance | | `GITEA_TOKEN` | Gitea API token | | `GITEA_TIMEOUT` | Gitea API request timeout in seconds | | `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. | | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking | | `RUST_LOG` | *(optional)* Log level, defaults to `info` | | `SANDBOX_ENABLED` | *(optional)* Run reviews inside a devcontainer sandbox so the model can explore the repository with tools. Defaults to `false` | | `CONTAINER_RUNTIME` | *(optional)* Container runtime binary used for the sandbox (`docker` or `podman`). Defaults to `docker` | | `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` | ## Sandboxed reviews When `SANDBOX_ENABLED=true`, Herald reviews pull requests inside an ephemeral [Dev Container](https://containers.dev/). For each review it: 1. clones the pull request head into a temporary directory, 2. builds and starts the repository's devcontainer (`devcontainer-rs`), 3. lets the model explore the repository with read-only tools (`ls`, `read_file`, `grep`, `find`) executed inside the container, 4. posts the review and removes the container and the temporary clone. The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or `podman`). The repository must contain a `.devcontainer/devcontainer.json`. Each sandbox is isolated: it gets its own image tag, container and network. The container starts with network access so the `postCreateCommand` / `postStartCommand` hooks can install dependencies (e.g. `npm install`); once the hooks have run, the container is disconnected from the network for the rest of the review. Every container command is bounded by a timeout, and the container, network and image are removed when the review ends (including on failure). ## Development The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension). Open the project and reopen it in the container — the Rust toolchain is pre-installed. **Without Dev Container**, you just need a Rust toolchain: ```sh rustup toolchain install stable cargo run ``` Copy `.env.example` to `.env` and fill in your values before running. ## Metrics Herald optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper. Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then available at `http://:9100/metrics`. ### Exposed metrics | Metric | Type | Description | |---|---|---| | `herald_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) | | `herald_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) | | `herald_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) | | `herald_bot_tasks_active` | gauge | Bot tasks currently in progress | | `herald_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) | | `herald_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) | | `herald_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) | ### OTel collector example ```yaml receivers: prometheus: config: scrape_configs: - job_name: herald scrape_interval: 15s static_configs: - targets: ["herald:9100"] service: pipelines: metrics: receivers: [prometheus] exporters: [otlp] ```