150 lines
6.9 KiB
Markdown
150 lines
6.9 KiB
Markdown
# 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 |
|
|
| `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_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
|
|
| `DOCKER_HOST` | *(optional)* Container daemon socket Herald drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` |
|
|
|
|
## Sandboxed reviews
|
|
|
|
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`) — or, when
|
|
the repository has none, a default one based on `debian:stable-slim`,
|
|
3. reads the pull request diff and file list from the Gitea API with
|
|
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
|
lines changed — additions and deletions, with the line numbers of the new and
|
|
old versions of the file respectively — then lets it explore the repository
|
|
with read-only tools (`ls`, `file_size`, `read_file`, `grep`, `find`) run inside
|
|
the container: the code itself is not sent, so the model reads it at those lines,
|
|
4. posts the review, anchoring each comment on the added or removed line it
|
|
refers to, and removes the container and the temporary clone.
|
|
|
|
Generated files are left out of the changes handed to the model: lockfiles
|
|
(`Cargo.lock`, `package-lock.json`, `yarn.lock`, `go.sum`…) are machine-written
|
|
dependency churn whose thousands of lines would drown the code under review, and
|
|
they are never a place where a comment belongs.
|
|
|
|
Each comment is tagged with a severity — `bug`, `security`, `performance` or
|
|
`maintainability` — shown at the start of the comment, and the summary also lists
|
|
what the pull request does well.
|
|
|
|
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
|
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
|
Docker-compatible socket. When the repository contains a
|
|
`.devcontainer/devcontainer.json` (or `.devcontainer.json`), Herald uses it;
|
|
otherwise it falls back to a default devcontainer that pulls `debian:stable-slim`
|
|
and runs the review in `/workspace`.
|
|
|
|
Herald can therefore run inside a container with only that socket mounted (no
|
|
shared workspace directory is required): the clone is streamed to the daemon over
|
|
the socket, like the build context, instead of being bind-mounted from a host
|
|
path the daemon would have to see. This is the setup the `Containerfile`
|
|
produces, e.g.:
|
|
|
|
```sh
|
|
podman run --env-file=.env -p 3001:3001 \
|
|
-v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \
|
|
herald:latest
|
|
```
|
|
|
|
The `runArgs` of that file are ignored: they come from an untrusted pull request,
|
|
and one of them (`--network host`) would attach the container to another network
|
|
and quietly defeat the network cut described below. A devcontainer that relies on
|
|
them (`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
|
|
|
|
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, along with rootless podman, so sandboxed reviews can be exercised locally: start its API socket with `podman system service --time=0 &` and set `DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`.
|
|
|
|
**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://<host>: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]
|
|
``` |