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
3.8 KiB
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:
- List open issues, open pull requests, and recent comments via the Gitea API (one client authenticated as the first personality — reads only).
- 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
seededflag is persisted per repo. - 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 for the implication.
Job execution
Each session is a docker.Job handed to a docker.Runner. The production
DockerRunner:
- Materialises a per-job scratch dir containing the prompt, a static
job.shentrypoint, 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. - Runs
docker run --rm --entrypoint /bin/bash <image> /teabot/job.shwith:- the scratch files bind-mounted (
:ro,zunder SELinux), - the tea config mounted at
$HOME/.config/tea/config.ymlandXDG_CONFIG_HOMEset, soteaacts as the bot identity, - git identity + a credential-store token so clones and pushes authenticate,
ANTHROPIC_API_KEY/ANTHROPIC_BASE_URLinjected only when configured (otherwise the mounted subscription credentials are used).
- the scratch files bind-mounted (
job.shconfigures git, clones the repo, and runsclaude --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.