Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in Docker containers to work issues and review PRs, acting as configurable bot personalities. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Architecture
|
||||
|
||||
teabot is a single binary with a small set of internal packages. The daemon
|
||||
polls Gitea, decides which events warrant work, and runs each unit of work as a
|
||||
disposable Docker container.
|
||||
|
||||
```
|
||||
main.go
|
||||
└── internal/cli cobra command tree (run, config)
|
||||
└── internal/dispatch poll loop, event filtering, job orchestration
|
||||
├── internal/gitea read-only Gitea REST client
|
||||
├── internal/state processed-event persistence (JSON)
|
||||
├── internal/prompt per-event prompt construction
|
||||
├── internal/config config + tea-config parsing
|
||||
└── internal/docker containerised Claude execution (Runner iface)
|
||||
```
|
||||
|
||||
## Poll cycle
|
||||
|
||||
`dispatch.Dispatcher` runs one `pollRepo` per watched repo each interval:
|
||||
|
||||
1. **List** open issues, open pull requests, and recent comments via the Gitea
|
||||
API (one client authenticated as the first personality — reads only).
|
||||
2. **Seed on first contact.** The very first poll of a repo records everything
|
||||
currently open/recent as *processed* without dispatching anything, so a fresh
|
||||
install does not stampede every existing item. The `seeded` flag is persisted
|
||||
per repo.
|
||||
3. **Classify & dispatch** subsequent events:
|
||||
- a new issue → implementer session,
|
||||
- a new pull request → reviewer session,
|
||||
- a new comment on a thread teabot **acted on** → follow-up session.
|
||||
|
||||
## Filtering rules (loop prevention + dedup)
|
||||
|
||||
Two independent guards decide whether an event becomes a job:
|
||||
|
||||
- **Loop prevention** — any event authored by one of teabot's own personality
|
||||
logins is skipped. This is what stops the bot reacting to its own PRs and
|
||||
comments in an infinite loop. Bot-authored items are still *recorded* as
|
||||
processed so they are never reconsidered.
|
||||
- **Dedup** — every dispatched issue/PR index and every seen comment ID is
|
||||
recorded in the state store (`~/.local/state/teabot/state.json`). An item is
|
||||
marked processed *before* its job starts, so a subsequent poll (or a restart
|
||||
mid-job) cannot double-launch it.
|
||||
|
||||
Comment follow-ups additionally require the parent issue/PR to be in the
|
||||
*acted-on* set — teabot only continues threads it started, never arbitrary
|
||||
comment threads. See [configuration](configuration.md) for the implication.
|
||||
|
||||
## Job execution
|
||||
|
||||
Each session is a `docker.Job` handed to a `docker.Runner`. The production
|
||||
`DockerRunner`:
|
||||
|
||||
1. Materialises a per-job scratch dir containing the prompt, a static
|
||||
`job.sh` entrypoint, a **private copy** of the Claude config dir (so
|
||||
subscription-token refreshes never mutate the host's `~/.claude`), and a copy
|
||||
of the personality's tea config.
|
||||
2. Runs `docker run --rm --entrypoint /bin/bash <image> /teabot/job.sh` with:
|
||||
- the scratch files bind-mounted (`:ro,z` under SELinux),
|
||||
- the tea config mounted at `$HOME/.config/tea/config.yml` and
|
||||
`XDG_CONFIG_HOME` set, so `tea` acts as the bot identity,
|
||||
- git identity + a credential-store token so clones and pushes authenticate,
|
||||
- `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` injected only when configured
|
||||
(otherwise the mounted subscription credentials are used).
|
||||
3. `job.sh` configures git, clones the repo, and runs
|
||||
`claude --print --dangerously-skip-permissions < /teabot/prompt.txt`.
|
||||
|
||||
The `Runner` interface means the whole dispatch layer is unit-testable with a
|
||||
fake runner — no Docker daemon required. Concurrency is bounded by a semaphore
|
||||
sized to `max_concurrent`, and every job has a `job_timeout`.
|
||||
|
||||
## Container image
|
||||
|
||||
teabot reuses the existing **`git.unkin.net/unkin/agent-dev:latest`** image,
|
||||
which already bundles the `claude` CLI plus a Go/Node/Python/`tea` developer
|
||||
toolchain and language servers. The image is configurable via `job_image`, so a
|
||||
purpose-built image can be substituted without code changes.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `teabot config`
|
||||
|
||||
Inspect and scaffold teabot configuration.
|
||||
|
||||
## `teabot config init`
|
||||
|
||||
Writes a fully-commented example config to the `--config` path (default
|
||||
`~/.config/teabot/config.yaml`).
|
||||
|
||||
```bash
|
||||
teabot config init # writes ~/.config/teabot/config.yaml
|
||||
teabot config init --force # overwrite an existing file
|
||||
teabot config init -c ./my.yaml
|
||||
```
|
||||
|
||||
It refuses to overwrite an existing file unless `--force` is given. After
|
||||
writing, edit the file and create the tea config files each personality
|
||||
references (see [configuration](configuration.md)).
|
||||
|
||||
## `teabot config show`
|
||||
|
||||
Loads, validates, and prints the effective configuration — defaults applied and
|
||||
each personality resolved from its tea config (username shown, token never
|
||||
printed). A validation error here means `teabot run` would fail too, so this is
|
||||
the quickest pre-flight check.
|
||||
|
||||
```bash
|
||||
teabot config show
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
config: /home/ben/.config/teabot/config.yaml
|
||||
gitea_url: https://git.unkin.net
|
||||
state_dir: /home/ben/.local/state/teabot
|
||||
poll_interval: 1m0s
|
||||
job_timeout: 30m0s
|
||||
max_concurrent: 2
|
||||
job_image: git.unkin.net/unkin/agent-dev:latest
|
||||
claude_config: /home/ben/.claude
|
||||
repos:
|
||||
- unkin/teabot
|
||||
personalities:
|
||||
- implementer (role=implementer, login=teabot-implementer)
|
||||
- reviewer (role=reviewer, login=teabot-reviewer)
|
||||
```
|
||||
|
||||
## Shell completions
|
||||
|
||||
teabot uses cobra, which provides a `completion` subcommand. The RPM installs
|
||||
bash/zsh/fish completions to the standard system paths automatically. To load
|
||||
ad-hoc:
|
||||
|
||||
```bash
|
||||
source <(teabot completion bash) # or zsh / fish
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# Configuration
|
||||
|
||||
teabot reads a single YAML file, by default
|
||||
`$XDG_CONFIG_HOME/teabot/config.yaml` (i.e. `~/.config/teabot/config.yaml`).
|
||||
Override the path with `--config/-c`. Generate a starting point with
|
||||
[`teabot config init`](config.md), and validate the effective settings with
|
||||
`teabot config show`.
|
||||
|
||||
## Top-level keys
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `gitea_url` | `https://git.unkin.net` | Base URL of the Gitea instance to poll. |
|
||||
| `repos` | *(required)* | List of `owner/name` repositories to watch. |
|
||||
| `poll_interval` | `60s` | Delay between poll cycles (Go duration). |
|
||||
| `max_concurrent` | `2` | Maximum job containers running at once. |
|
||||
| `job_timeout` | `30m` | Per-session wall-clock timeout. |
|
||||
| `job_image` | `git.unkin.net/unkin/agent-dev:latest` | Container image each session runs in. |
|
||||
| `container_home` | `/home/agent` | Home dir inside `job_image` (mount target). |
|
||||
| `claude_config_dir` | `~/.claude` | Host dir with Claude Code credentials. |
|
||||
| `anthropic_api_key` | *(unset)* | If set, injected as `ANTHROPIC_API_KEY`. |
|
||||
| `anthropic_base_url` | *(unset)* | If set, injected as `ANTHROPIC_BASE_URL`. |
|
||||
| `state_dir` | `~/.local/state/teabot` | Where processed-event state is persisted. |
|
||||
| `personalities` | *(required)* | Bot identities (see below). |
|
||||
|
||||
`~` and `~/` are expanded in path-valued keys.
|
||||
|
||||
## Personalities
|
||||
|
||||
A personality is a distinct Gitea bot account. teabot must be able to act as
|
||||
different identities — e.g. an implementer that opens PRs and a separate reviewer
|
||||
that critiques them — so each personality points at its **own tea config file**
|
||||
rather than sharing your personal `~/.config/tea/config.yml`.
|
||||
|
||||
```yaml
|
||||
personalities:
|
||||
- name: implementer
|
||||
tea_config: ~/.config/teabot/tea-implementer.yml
|
||||
role: implementer # implementer | reviewer | both
|
||||
git_name: Teabot Implementer
|
||||
git_email: teabot-implementer@unkin.net
|
||||
- name: reviewer
|
||||
tea_config: ~/.config/teabot/tea-reviewer.yml
|
||||
role: reviewer
|
||||
git_name: Teabot Reviewer
|
||||
git_email: teabot-reviewer@unkin.net
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Label used in logs and prompts. |
|
||||
| `tea_config` | Path to a `tea` config.yml holding this bot's login. teabot reads the API token + username from it and mounts it into the job container. |
|
||||
| `role` | `implementer` (issues), `reviewer` (pull requests), or `both`. Defaults to `both`. |
|
||||
| `git_name` / `git_email` | Commit identity set inside the container. |
|
||||
|
||||
Create each tea config with the normal tea workflow, pointing `HOME`/
|
||||
`XDG_CONFIG_HOME` at a scratch dir so it lands somewhere dedicated, or copy an
|
||||
existing `config.yml` and edit the token. The file format is exactly tea's own:
|
||||
|
||||
```yaml
|
||||
logins:
|
||||
- name: teabot-implementer
|
||||
url: https://git.unkin.net
|
||||
token: <bot-api-token>
|
||||
default: true
|
||||
user: teabot-implementer
|
||||
```
|
||||
|
||||
teabot picks the login whose `url` matches `gitea_url`, else the `default`, else
|
||||
the first. The `user` field is the bot's username — teabot uses it for **loop
|
||||
prevention** (it never reacts to events authored by any personality's username).
|
||||
|
||||
At least one personality must be able to implement and at least one to review,
|
||||
or config validation fails.
|
||||
|
||||
## Claude credentials
|
||||
|
||||
By default teabot uses your Claude **subscription** auth: it copies
|
||||
`claude_config_dir` (default `~/.claude`) into a per-job scratch dir and mounts
|
||||
that copy read-write into the container, so the session can refresh tokens
|
||||
without ever mutating your real config.
|
||||
|
||||
Alternatively set `anthropic_api_key` (and optionally `anthropic_base_url` for a
|
||||
gateway such as LiteLLM). When present these are injected as environment
|
||||
variables and take precedence over the mounted subscription credentials.
|
||||
|
||||
## SELinux
|
||||
|
||||
This host is Fedora, so Docker bind mounts are relabelled with `:z`. teabot does
|
||||
this automatically. If you run under a different security model, the relabel
|
||||
suffix is a field on `DockerRunner` (`SELinuxLabel`).
|
||||
|
||||
## A note on comment follow-ups
|
||||
|
||||
teabot only continues comment threads on issues/PRs it **acted on** itself
|
||||
(opened a PR for, or reviewed). A comment on an unrelated thread is recorded but
|
||||
ignored. Combined with loop prevention (bot-authored comments are skipped), this
|
||||
means teabot will not, for example, keep answering an issue it decided not to
|
||||
implement — it engages a thread only after it has taken an action there.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# `teabot run`
|
||||
|
||||
Runs the daemon in the foreground. It polls the configured repos on `poll_interval`
|
||||
and dispatches Claude sessions. This is the command the systemd user unit executes.
|
||||
|
||||
```bash
|
||||
teabot run [flags]
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--once` | `false` | Run a single poll cycle then exit. Blocks until every job dispatched during the cycle finishes — ideal for testing. |
|
||||
| `--log-json` | `false` | Emit structured JSON logs instead of text. |
|
||||
| `--log-level` | `info` | `debug`, `info`, `warn`, or `error`. |
|
||||
| `-c`, `--config` | `~/.config/teabot/config.yaml` | Config file path (inherited from the root command). |
|
||||
|
||||
Logs go to stderr (captured by the systemd journal). teabot handles `SIGINT`/
|
||||
`SIGTERM` by stopping the poll loop and waiting for in-flight jobs to finish
|
||||
before exiting.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# One cycle, verbose, to see what would be dispatched.
|
||||
teabot run --once --log-level debug
|
||||
|
||||
# Foreground daemon with JSON logs.
|
||||
teabot run --log-json
|
||||
```
|
||||
|
||||
## systemd (user service)
|
||||
|
||||
The RPM installs a user unit at `/usr/lib/systemd/user/teabot.service`.
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now teabot
|
||||
journalctl --user -u teabot -f
|
||||
```
|
||||
|
||||
Because it is a **user** service it runs as your login user, so it inherits your
|
||||
Docker access and your `~/.claude` credentials. To keep it running when you are
|
||||
not logged in, enable lingering:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
## Requirements at runtime
|
||||
|
||||
- `docker` on `PATH` and usable by the running user.
|
||||
- The configured `job_image` pullable from the registry.
|
||||
- Each personality's tea config file present and readable.
|
||||
- Claude credentials available (subscription `~/.claude` or an
|
||||
`anthropic_api_key` in the config).
|
||||
Reference in New Issue
Block a user