Add teabot daemon implementation
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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:
2026-07-26 23:36:21 +10:00
parent 0a3060881e
commit 1b4448afb4
43 changed files with 4182 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
# built binary (repo root only)
/teabot
# cross-compiled release artifacts (e.g. teabot-linux-amd64)
/teabot-*
# build output: binaries, completions, RPM
dist/
# local checksums manifest produced by the release pipeline
sha256sums.txt
+17
View File
@@ -0,0 +1,17 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-unit-tests
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+144
View File
@@ -0,0 +1,144 @@
when:
- event: tag
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Build the dist/ binary (consumed by the RPM step) plus the cross-platform
# binaries attached to the Gitea release.
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
# Shell vars/expansions are escaped as $$ so Woodpecker leaves them for
# the shell; ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- |
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}"
GOOS="$$os" GOARCH="$$arch" \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "teabot-$${os}-$${arch}" .
done
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Package the built binary + completions + systemd unit + example config into
# an RPM.
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
# repodata regenerates automatically).
- name: upload-rpm
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
# artifactapi has no HEAD route (returns 405); probe with GET against
# the served path (RPMs are stored under Packages/) to avoid re-upload.
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
continue
fi
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
curl -f -X PUT \
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
-H "Content-Type: application/x-rpm" \
--data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
# Cut a Gitea release with the cross-platform binaries + RPM attached.
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
# $$ escapes shell vars so Woodpecker doesn't blank them; ${CI_COMMIT_TAG}
# and ${CI_REPO} are real Woodpecker vars. Find the previous release tag
# for the changelog range, skipping tags on the current commit.
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
PREV_TAG=""
for t in $$(git tag --sort=-v:refname); do
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
PREV_TAG="$$t"; break
fi
done
if [ -n "$$PREV_TAG" ]; then
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="teabot-linux-amd64 teabot-linux-arm64 teabot-darwin-amd64 teabot-darwin-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
--login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golangci/golangci-lint:latest
commands:
- golangci-lint run ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+76
View File
@@ -0,0 +1,76 @@
# AGENTS.md
## Project Overview
teabot is a Go daemon (systemd **user** service) that watches Gitea repositories
and dispatches **one-shot Claude Code sessions in Docker containers** to work
issues and review pull requests. It polls the Gitea API for new issues, pull
requests, and comments; for each event it runs a throwaway container that clones
the repo and runs `claude --print` with a task-specific prompt, acting as a
configurable bot **personality**.
## Structure
```
main.go # package main; wires version into internal/cli
internal/cli/ # cobra command tree: run, config init/show
internal/config/ # config.yaml + tea-config parsing, defaults, validation
internal/gitea/ # read-only Gitea REST client (issues/pulls/comments/diff)
internal/state/ # processed-event persistence (JSON, atomic writes)
internal/prompt/ # per-event prompt construction (issue/pull/follow-up)
internal/dispatch/ # poll loop, event filtering, loop prevention, job orchestration
internal/docker/ # containerised Claude execution behind a Runner interface
docs/ # architecture + configuration + per-subcommand docs
packaging/nfpm.yaml # RPM spec (binary + completions + systemd unit + example config)
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
systemd/teabot.service # systemd user unit
config.example.yaml # example config (also embedded for `config init`)
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
```
## Build / test
```bash
make build # -> dist/teabot (CGO disabled, static)
make test # go test -race ./...
make rpm # build + package RPM (needs nfpm)
```
Requires Go 1.25+. Deps: `github.com/spf13/cobra`, `gopkg.in/yaml.v3`.
## Design notes for contributors
- **Loop prevention is load-bearing.** Any event authored by a personality's
Gitea username is skipped (but recorded). Never remove this — it is what stops
the bot reacting to its own PRs/comments forever.
- **Dedup before dispatch.** Issues/PRs/comments are marked processed in the
state store *before* their job starts, so a re-poll or mid-job restart cannot
double-launch. State lives at `~/.local/state/teabot/state.json`.
- **First-contact seeding.** The first poll of a repo records existing open
items as processed without dispatching, so a fresh install doesn't stampede.
- **Follow-ups only on acted threads.** Comment follow-ups fire only when teabot
previously opened a PR for / reviewed the parent issue/PR.
- **Docker is behind `docker.Runner`.** All dispatch logic is tested with a fake
runner; no Docker daemon is needed for `go test`. `DockerRunner.buildArgs` is
pure given a job dir so the `docker run` argument list is unit-tested directly.
- **Personalities use tea configs.** tea persists to `$XDG_CONFIG_HOME/tea` and
has no `--config` flag, so each personality's config is mounted into the
container and `XDG_CONFIG_HOME` is set there.
- **Image reuse.** The default `job_image` is `agent-dev`, which already ships
the claude CLI + dev toolchain; teabot does not build its own image.
## Testing conventions
Every package has meaningful unit tests (no rubber-stamps): config precedence &
validation & tea parsing, state persistence/dedup roundtrips, gitea client via
`httptest`, prompt content assertions, docker arg/mount/env construction, and
dispatch filtering/loop-prevention/seeding/follow-up routing via a fake client +
fake runner. `go build ./...` and `go test -race ./...` must pass.
## Conventions (house rules)
- Branches `benvin/<name>`. PR bodies: short "why" paragraph + present-tense
"how" bullets. HTTPS remotes for git.unkin.net (SSH blocked). Use `tea`, not
`gh`. Do not merge PRs yourself.
- Releases are Woodpecker pipelines triggered on `v*` tags; bump with
`make patch|minor|major`. Every CI step sets k8s resource requests+limits.
+70
View File
@@ -0,0 +1,70 @@
BINARY := teabot
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
.PHONY: all build test lint fmt vet clean install completions rpm rpm-package patch minor major _tag
all: build
# Build the teabot binary into dist/ (consumed by the RPM packaging step).
build:
@echo "building $(BINARY) $(VERSION)"
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) .
test:
go test -race ./...
vet:
go vet ./...
lint:
golangci-lint run ./...
fmt:
gofmt -w .
clean:
rm -rf $(DIST) $(BINARY)
install:
go install $(GOFLAGS) .
# Generate bash/zsh/fish completions into dist/completions.
completions: build
@mkdir -p $(DIST)/completions
$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
# Build the binary then package it (with completions + systemd unit) into an RPM.
rpm: build rpm-package
# Package an already-built binary into an RPM (used by CI after the build step).
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# Bump helpers — read the latest semver tag and create the next one, then push
# the tag to trigger the release pipeline.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+64 -1
View File
@@ -1,3 +1,66 @@
# teabot
A Go daemon that watches Gitea repos and dispatches one-shot Claude Code sessions in Docker to work issues and review PRs.
teabot is a Go daemon (shipped as a systemd **user** service) that watches Gitea
repositories and dispatches **one-shot Claude Code sessions in Docker containers**
to work issues and review pull requests.
On each poll it looks for new issues, new pull requests, and new comments across
the repos it watches. For each event it launches a throwaway container that
clones the repo fresh and runs `claude --print` with a task-specific prompt:
- **New issue** → an *implementer* personality reviews it and, if it warrants a
change, opens a PR that closes the issue (following the house PR conventions).
- **New pull request** → a *reviewer* personality critiques the diff and posts an
approving or change-requesting review.
- **New comment** on a thread teabot already engaged with → a follow-up session
responds or makes the requested change.
Each personality is a distinct Gitea bot account backed by its own `tea` config
file, so an implementer bot can open PRs that a separate reviewer bot critiques.
teabot never reacts to events authored by its own personalities (loop
prevention) and persists processed state so restarts don't re-trigger work.
## Quick start
```bash
# 1. Create the config and edit it.
teabot config init
$EDITOR ~/.config/teabot/config.yaml
# 2. Create a tea login file per personality (a normal tea config.yml).
tea logins add --name teabot-impl --url https://git.unkin.net --token <impl-token>
tea logins add --name teabot-review --url https://git.unkin.net --token <review-token>
# point each personality's tea_config: at the resulting config.yml
# 3. Validate, then run a single cycle to test.
teabot config show
teabot run --once
# 4. Enable the daemon.
systemctl --user enable --now teabot
```
## Documentation
- [Architecture](docs/architecture.md) — how polling, dispatch, and the Docker
execution model fit together.
- [Configuration](docs/configuration.md) — every config key, personalities, and
Claude/tea credential handling.
- [`teabot run`](docs/run.md) — running the daemon, `--once`, logging, systemd.
- [`teabot config`](docs/config.md) — the `init` and `show` subcommands.
## Build
```bash
make build # -> dist/teabot (static, CGO disabled)
make test # go test -race ./...
make rpm # build + package an RPM (needs nfpm)
```
Requires Go 1.25+. A `v*` tag triggers the release pipeline: a Gitea release
with cross-platform binaries plus an RPM published to the artifactapi
`rpm-internal` repo.
## License
MIT
+53
View File
@@ -0,0 +1,53 @@
# teabot configuration
# Location: $XDG_CONFIG_HOME/teabot/config.yaml (default ~/.config/teabot/config.yaml)
# Base URL of the Gitea instance to watch.
gitea_url: https://git.unkin.net
# How often to poll each repo.
poll_interval: 60s
# Repositories to watch, in owner/name form.
repos:
- unkin/teabot
# Maximum number of Claude job containers running at once.
max_concurrent: 2
# Per-session wall-clock timeout.
job_timeout: 30m
# Container image each session runs in. The default already ships the Claude
# CLI plus a Go/Node/Python/tea developer toolchain.
job_image: git.unkin.net/unkin/agent-dev:latest
# Home directory inside job_image (mount target for tea/claude config).
container_home: /home/agent
# Host directory holding Claude Code credentials (subscription auth). A private
# copy is mounted into each container so token refreshes never touch this dir.
claude_config_dir: ~/.claude
# Optional: use an Anthropic API key / gateway instead of subscription auth.
# When set these are injected as ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL.
# anthropic_api_key: ""
# anthropic_base_url: ""
# Optional: override the state directory (default ~/.local/state/teabot).
# state_dir: ~/.local/state/teabot
# Bot personalities. Each is a distinct Gitea account backed by its own tea
# config file (create it with: tea logins add --name <bot> ...). teabot reads
# the token + username from that file and mounts it into the container so tea
# acts as this identity. Roles: implementer, reviewer, both.
personalities:
- name: implementer
tea_config: ~/.config/teabot/tea-implementer.yml
role: implementer
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
+78
View File
@@ -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.
+57
View File
@@ -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
```
+99
View File
@@ -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
View File
@@ -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).
+13
View File
@@ -0,0 +1,13 @@
module git.unkin.net/unkin/teabot
go 1.25
require (
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+13
View File
@@ -0,0 +1,13 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+102
View File
@@ -0,0 +1,102 @@
package cli
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
// runCLI executes the root command with args and returns combined stdout.
func runCLI(t *testing.T, args ...string) (string, error) {
t.Helper()
root := newRootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
err := root.Execute()
return out.String(), err
}
func TestConfigInitWritesAndRefusesOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
out, err := runCLI(t, "config", "init", "--config", path)
if err != nil {
t.Fatalf("config init: %v", err)
}
if !strings.Contains(out, "wrote example config") {
t.Errorf("unexpected output: %q", out)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading written config: %v", err)
}
if !strings.Contains(string(data), "personalities:") {
t.Error("written config missing personalities section")
}
// Second init without --force must fail.
if _, err := runCLI(t, "config", "init", "--config", path); err == nil {
t.Error("expected error re-initialising existing config without --force")
}
// With --force it should succeed.
if _, err := runCLI(t, "config", "init", "--config", path, "--force"); err != nil {
t.Errorf("config init --force: %v", err)
}
}
func TestConfigShowValidatesAndPrints(t *testing.T) {
dir := t.TempDir()
tea := filepath.Join(dir, "tea.yml")
if err := os.WriteFile(tea, []byte("logins:\n - name: b\n url: https://git.unkin.net\n token: tok\n default: true\n user: botuser\n"), 0o600); err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(dir, "config.yaml")
body := "gitea_url: https://git.unkin.net\nrepos: [unkin/teabot]\npersonalities:\n" +
" - {name: solo, tea_config: " + tea + ", role: both, git_name: S, git_email: s@x}\n"
if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
out, err := runCLI(t, "config", "show", "--config", cfgPath)
if err != nil {
t.Fatalf("config show: %v", err)
}
for _, want := range []string{"unkin/teabot", "solo", "botuser", "agent-dev"} {
if !strings.Contains(out, want) {
t.Errorf("config show output missing %q\n%s", want, out)
}
}
}
func TestConfigShowReportsInvalidConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
// No repos, no personalities -> validation error.
if err := os.WriteFile(cfgPath, []byte("gitea_url: https://git.unkin.net\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := runCLI(t, "config", "show", "--config", cfgPath); err == nil {
t.Error("expected validation error for empty config")
}
}
func TestNewLoggerLevels(t *testing.T) {
for _, lvl := range []string{"debug", "info", "warn", "error", "unknown"} {
if l := newLogger(false, lvl); l == nil {
t.Errorf("newLogger(%q) returned nil", lvl)
}
}
if l := newLogger(true, "info"); l == nil {
t.Error("JSON logger nil")
}
// Sanity: debug logger actually enables debug level.
if !newLogger(false, "debug").Enabled(nil, slog.LevelDebug) { //nolint:staticcheck
t.Error("debug logger should enable debug level")
}
}
+83
View File
@@ -0,0 +1,83 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"git.unkin.net/unkin/teabot/internal/config"
)
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Inspect and scaffold teabot configuration",
}
cmd.AddCommand(newConfigInitCmd())
cmd.AddCommand(newConfigShowCmd())
return cmd
}
func newConfigInitCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "init",
Short: "Write an example config file",
RunE: func(cmd *cobra.Command, _ []string) error {
path := configPath
if _, err := os.Stat(path); err == nil && !force {
return fmt.Errorf("config %s already exists (use --force to overwrite)", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path, []byte(config.Example), 0o644); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote example config to %s\n", path)
fmt.Fprintln(cmd.OutOrStdout(), "edit it, then create the referenced tea config files with `tea logins add`.")
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing config file")
return cmd
}
func newConfigShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Validate and print the effective configuration",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "config: %s\n", configPath)
fmt.Fprintf(out, "gitea_url: %s\n", cfg.GiteaURL)
fmt.Fprintf(out, "state_dir: %s\n", cfg.StateDirOrDefault())
fmt.Fprintf(out, "poll_interval: %s\n", cfg.PollInterval)
fmt.Fprintf(out, "job_timeout: %s\n", cfg.JobTimeout)
fmt.Fprintf(out, "max_concurrent: %d\n", cfg.MaxConcurrent)
fmt.Fprintf(out, "job_image: %s\n", cfg.JobImage)
fmt.Fprintf(out, "claude_config: %s\n", cfg.ClaudeConfigDir)
if cfg.AnthropicBaseURL != "" {
fmt.Fprintf(out, "anthropic_base_url: %s\n", cfg.AnthropicBaseURL)
}
if cfg.AnthropicAPIKey != "" {
fmt.Fprintf(out, "anthropic_api_key: (set)\n")
}
fmt.Fprintf(out, "repos:\n")
for _, r := range cfg.Repos {
fmt.Fprintf(out, " - %s\n", r)
}
fmt.Fprintf(out, "personalities:\n")
for _, p := range cfg.Personalities {
fmt.Fprintf(out, " - %s (role=%s, login=%s)\n", p.Name, p.Role, p.Login)
}
return nil
},
}
}
+45
View File
@@ -0,0 +1,45 @@
// Package cli wires teabot's cobra command tree.
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
"git.unkin.net/unkin/teabot/internal/config"
)
var (
// configPath is the --config flag shared by the commands that need it.
configPath string
buildVer string
)
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "teabot",
Short: "Watch Gitea repos and dispatch one-shot Claude Code sessions",
Long: "teabot polls Gitea repositories for new issues, pull requests, and comments,\n" +
"then dispatches one-shot Claude Code sessions in Docker containers to implement\n" +
"issues and review pull requests using configurable bot personalities.",
SilenceUsage: true,
SilenceErrors: true,
Version: buildVer,
}
root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultConfigPath(),
"path to teabot config file")
root.AddCommand(newRunCmd())
root.AddCommand(newConfigCmd())
return root
}
// Execute runs the CLI with the given build version.
func Execute(version string) {
buildVer = version
if err := newRootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "teabot:", err)
os.Exit(1)
}
}
+97
View File
@@ -0,0 +1,97 @@
package cli
import (
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/dispatch"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
"git.unkin.net/unkin/teabot/internal/state"
)
func newRunCmd() *cobra.Command {
var (
once bool
logJSON bool
logLevel string
)
cmd := &cobra.Command{
Use: "run",
Short: "Run the teabot daemon (foreground)",
Long: "run starts teabot in the foreground: it polls the configured repos on an\n" +
"interval and dispatches Claude sessions. Use --once for a single poll cycle\n" +
"(handy for testing). This command is what the systemd user unit executes.",
RunE: func(cmd *cobra.Command, _ []string) error {
logger := newLogger(logJSON, logLevel)
cfg, err := config.Load(configPath)
if err != nil {
return err
}
store, err := state.New(cfg.StateDirOrDefault())
if err != nil {
return err
}
// One Gitea client, authenticated as the first personality, is
// enough for read-only polling; actions happen inside containers as
// the per-event personality.
pollTok := cfg.Personalities[0].Token
client := gitea.NewClient(cfg.GiteaURL, pollTok)
runner := docker.NewDockerRunner()
runner.Stdout = os.Stderr
d := dispatch.New(cfg, store, client, runner, logger)
logger.Info("teabot starting",
"repos", cfg.Repos,
"personalities", len(cfg.Personalities),
"poll_interval", cfg.PollInterval.String(),
"max_concurrent", cfg.MaxConcurrent,
"job_image", cfg.JobImage,
"once", once)
if once {
return d.PollOnce(cmd.Context(), true)
}
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
return d.Run(ctx)
},
}
cmd.Flags().BoolVar(&once, "once", false, "run a single poll cycle then exit")
cmd.Flags().BoolVar(&logJSON, "log-json", false, "emit structured JSON logs")
cmd.Flags().StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
return cmd
}
func newLogger(jsonOut bool, level string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
var h slog.Handler
if jsonOut {
h = slog.NewJSONHandler(os.Stderr, opts)
} else {
h = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(h)
}
+286
View File
@@ -0,0 +1,286 @@
// Package config loads and validates teabot's daemon configuration and the
// per-personality tea config files it references.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
// AppName is used for XDG config/state directory names.
AppName = "teabot"
// DefaultGiteaURL is the Gitea instance teabot watches by default.
DefaultGiteaURL = "https://git.unkin.net"
// DefaultJobImage already ships claude-code plus a Go/Node/Python/tea
// developer toolchain, so teabot reuses it instead of building its own.
DefaultJobImage = "git.unkin.net/unkin/agent-dev:latest"
// DefaultPollInterval is how often each repo is polled when unset.
DefaultPollInterval = 60 * time.Second
// DefaultJobTimeout bounds a single Claude session.
DefaultJobTimeout = 30 * time.Minute
// DefaultMaxConcurrent caps simultaneously running job containers.
DefaultMaxConcurrent = 2
// DefaultContainerHome is the home directory inside the job image
// (the agent-dev image runs as the unprivileged "agent" user).
DefaultContainerHome = "/home/agent"
)
// Role describes what work a personality is allowed to perform.
type Role string
const (
// RoleImplementer handles new issues and issue-comment follow-ups.
RoleImplementer Role = "implementer"
// RoleReviewer handles new pull requests and PR-comment follow-ups.
RoleReviewer Role = "reviewer"
// RoleBoth handles every event kind.
RoleBoth Role = "both"
)
// Personality is a distinct Gitea bot identity backed by its own tea config
// file. Different personalities let, for example, an "implementer" account open
// PRs while a separate "reviewer" account critiques them.
type Personality struct {
// Name is the human-readable label used in logs and prompts.
Name string `yaml:"name"`
// TeaConfig is the path to a tea config.yml holding this bot's Gitea
// login (token + url + username). teabot parses it for the API token and
// mounts it into the job container so tea acts as this identity.
TeaConfig string `yaml:"tea_config"`
// Role gates which event kinds this personality reacts to.
Role Role `yaml:"role"`
// GitName / GitEmail set the commit identity inside the container.
GitName string `yaml:"git_name"`
GitEmail string `yaml:"git_email"`
// Login is populated at load time from the parsed tea config: the Gitea
// username. Events authored by any personality's login are ignored so the
// bot never reacts to its own comments (loop prevention).
Login string `yaml:"-"`
// Token is populated at load time from the parsed tea config: the API
// token used for polling as this identity. Never written back to disk.
Token string `yaml:"-"`
// URL is populated at load time from the parsed tea config: the instance
// URL of this login.
URL string `yaml:"-"`
}
// CanImplement reports whether the personality reacts to issue events.
func (p Personality) CanImplement() bool { return p.Role == RoleImplementer || p.Role == RoleBoth }
// CanReview reports whether the personality reacts to pull-request events.
func (p Personality) CanReview() bool { return p.Role == RoleReviewer || p.Role == RoleBoth }
// Config is teabot's top-level configuration (~/.config/teabot/config.yaml).
type Config struct {
// GiteaURL is the base URL of the Gitea instance to poll.
GiteaURL string `yaml:"gitea_url"`
// Repos is the list of owner/name repositories to watch.
Repos []string `yaml:"repos"`
// PollInterval is the delay between poll cycles.
PollInterval time.Duration `yaml:"poll_interval"`
// StateDir overrides the XDG state directory used to persist processed
// events. Empty means $XDG_STATE_HOME/teabot (default ~/.local/state/teabot).
StateDir string `yaml:"state_dir"`
// MaxConcurrent caps simultaneously running job containers.
MaxConcurrent int `yaml:"max_concurrent"`
// JobTimeout bounds a single dispatched Claude session.
JobTimeout time.Duration `yaml:"job_timeout"`
// JobImage is the container image each session runs in.
JobImage string `yaml:"job_image"`
// ContainerHome is the home directory inside JobImage that mounts target.
ContainerHome string `yaml:"container_home"`
// ClaudeConfigDir is the host directory holding Claude Code credentials
// (subscription auth), mounted into each container. Empty means ~/.claude.
ClaudeConfigDir string `yaml:"claude_config_dir"`
// AnthropicAPIKey, when set, is injected as ANTHROPIC_API_KEY instead of
// relying on the mounted subscription credentials.
AnthropicAPIKey string `yaml:"anthropic_api_key"`
// AnthropicBaseURL, when set, is injected as ANTHROPIC_BASE_URL.
AnthropicBaseURL string `yaml:"anthropic_base_url"`
// Personalities are the bot identities teabot dispatches as.
Personalities []Personality `yaml:"personalities"`
}
// Load reads and validates the config at path, applying defaults and resolving
// each personality's tea config into a token/login/url.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %s: %w", path, err)
}
cfg.applyDefaults()
if err := cfg.resolvePersonalities(); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) applyDefaults() {
if c.GiteaURL == "" {
c.GiteaURL = DefaultGiteaURL
}
c.GiteaURL = strings.TrimRight(c.GiteaURL, "/")
if c.PollInterval <= 0 {
c.PollInterval = DefaultPollInterval
}
if c.MaxConcurrent <= 0 {
c.MaxConcurrent = DefaultMaxConcurrent
}
if c.JobTimeout <= 0 {
c.JobTimeout = DefaultJobTimeout
}
if c.JobImage == "" {
c.JobImage = DefaultJobImage
}
if c.ContainerHome == "" {
c.ContainerHome = DefaultContainerHome
}
if c.ClaudeConfigDir == "" {
if home, err := os.UserHomeDir(); err == nil {
c.ClaudeConfigDir = filepath.Join(home, ".claude")
}
} else {
c.ClaudeConfigDir = expandHome(c.ClaudeConfigDir)
}
if c.StateDir != "" {
c.StateDir = expandHome(c.StateDir)
}
for i := range c.Personalities {
if c.Personalities[i].Role == "" {
c.Personalities[i].Role = RoleBoth
}
c.Personalities[i].TeaConfig = expandHome(c.Personalities[i].TeaConfig)
}
}
// resolvePersonalities parses each personality's tea config file and fills in
// its token, login, and instance URL.
func (c *Config) resolvePersonalities() error {
for i := range c.Personalities {
p := &c.Personalities[i]
if p.TeaConfig == "" {
return fmt.Errorf("personality %q: tea_config is required", p.Name)
}
login, err := ParseTeaConfig(p.TeaConfig, c.GiteaURL)
if err != nil {
return fmt.Errorf("personality %q: %w", p.Name, err)
}
p.Login = login.User
p.Token = login.Token
p.URL = login.URL
}
return nil
}
// Validate checks the config is internally consistent and usable.
func (c *Config) Validate() error {
if len(c.Repos) == 0 {
return fmt.Errorf("no repos configured")
}
for _, r := range c.Repos {
if !strings.Contains(strings.Trim(r, "/"), "/") {
return fmt.Errorf("repo %q must be in owner/name form", r)
}
}
if len(c.Personalities) == 0 {
return fmt.Errorf("at least one personality is required")
}
seen := map[string]bool{}
var haveImpl, haveReview bool
for _, p := range c.Personalities {
if p.Name == "" {
return fmt.Errorf("personality with empty name")
}
if seen[p.Name] {
return fmt.Errorf("duplicate personality name %q", p.Name)
}
seen[p.Name] = true
switch p.Role {
case RoleImplementer, RoleReviewer, RoleBoth:
default:
return fmt.Errorf("personality %q: invalid role %q", p.Name, p.Role)
}
if p.Token == "" {
return fmt.Errorf("personality %q: no token found in tea config", p.Name)
}
if p.Login == "" {
return fmt.Errorf("personality %q: no username found in tea config", p.Name)
}
haveImpl = haveImpl || p.CanImplement()
haveReview = haveReview || p.CanReview()
}
if !haveImpl {
return fmt.Errorf("no personality can implement (role implementer or both)")
}
if !haveReview {
return fmt.Errorf("no personality can review (role reviewer or both)")
}
return nil
}
// BotLogins returns the set of Gitea usernames belonging to configured
// personalities, used to skip events the bot authored itself.
func (c *Config) BotLogins() map[string]bool {
m := make(map[string]bool, len(c.Personalities))
for _, p := range c.Personalities {
if p.Login != "" {
m[p.Login] = true
}
}
return m
}
// ImplementerFor returns the personality that should handle issue work, or nil.
func (c *Config) ImplementerFor() *Personality {
for i := range c.Personalities {
if c.Personalities[i].CanImplement() {
return &c.Personalities[i]
}
}
return nil
}
// ReviewerFor returns the personality that should handle PR work, or nil.
func (c *Config) ReviewerFor() *Personality {
for i := range c.Personalities {
if c.Personalities[i].CanReview() {
return &c.Personalities[i]
}
}
return nil
}
// expandHome expands a leading ~/ to the user's home directory.
func expandHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
if p == "~" {
return home
}
return filepath.Join(home, p[2:])
}
}
return p
}
+280
View File
@@ -0,0 +1,280 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
// writeTeaConfig writes a minimal tea config.yml and returns its path.
func writeTeaConfig(t *testing.T, dir, name, url, token, user string, isDefault bool) string {
t.Helper()
path := filepath.Join(dir, name+".yml")
content := "logins:\n" +
" - name: " + name + "\n" +
" url: " + url + "\n" +
" token: " + token + "\n" +
" default: " + boolStr(isDefault) + "\n" +
" user: " + user + "\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("writing tea config: %v", err)
}
return path
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func writeConfig(t *testing.T, dir, body string) string {
t.Helper()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("writing config: %v", err)
}
return path
}
func TestLoadAppliesDefaultsAndResolvesPersonalities(t *testing.T) {
dir := t.TempDir()
impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok-impl", "implbot", true)
rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok-rev", "revbot", false)
body := `repos:
- unkin/teabot
personalities:
- name: implementer
tea_config: ` + impl + `
role: implementer
git_name: Impl Bot
git_email: impl@unkin.net
- name: reviewer
tea_config: ` + rev + `
role: reviewer
git_name: Rev Bot
git_email: rev@unkin.net
`
cfg, err := Load(writeConfig(t, dir, body))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GiteaURL != DefaultGiteaURL {
t.Errorf("GiteaURL default = %q, want %q", cfg.GiteaURL, DefaultGiteaURL)
}
if cfg.PollInterval != DefaultPollInterval {
t.Errorf("PollInterval default = %s, want %s", cfg.PollInterval, DefaultPollInterval)
}
if cfg.JobImage != DefaultJobImage {
t.Errorf("JobImage default = %q, want %q", cfg.JobImage, DefaultJobImage)
}
if cfg.MaxConcurrent != DefaultMaxConcurrent {
t.Errorf("MaxConcurrent default = %d, want %d", cfg.MaxConcurrent, DefaultMaxConcurrent)
}
// Personalities must be resolved from their tea configs.
if got := cfg.Personalities[0]; got.Login != "implbot" || got.Token != "tok-impl" {
t.Errorf("implementer resolved = login %q token %q", got.Login, got.Token)
}
if got := cfg.Personalities[1]; got.Login != "revbot" || got.Token != "tok-rev" {
t.Errorf("reviewer resolved = login %q token %q", got.Login, got.Token)
}
}
func TestLoadHonoursOverrides(t *testing.T) {
dir := t.TempDir()
tea := writeTeaConfig(t, dir, "both", "https://git.example.com", "tok", "bot", true)
body := `gitea_url: https://git.example.com/
poll_interval: 5s
job_timeout: 10m
max_concurrent: 7
job_image: example/img:1
repos:
- foo/bar
personalities:
- name: both
tea_config: ` + tea + `
role: both
git_name: Bot
git_email: bot@example.com
`
cfg, err := Load(writeConfig(t, dir, body))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GiteaURL != "https://git.example.com" {
t.Errorf("GiteaURL = %q (trailing slash not trimmed?)", cfg.GiteaURL)
}
if cfg.PollInterval != 5*time.Second {
t.Errorf("PollInterval = %s", cfg.PollInterval)
}
if cfg.JobTimeout != 10*time.Minute {
t.Errorf("JobTimeout = %s", cfg.JobTimeout)
}
if cfg.MaxConcurrent != 7 {
t.Errorf("MaxConcurrent = %d", cfg.MaxConcurrent)
}
}
func TestValidateErrors(t *testing.T) {
dir := t.TempDir()
tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true)
cases := map[string]string{
"no repos": `personalities:
- {name: a, tea_config: ` + tea + `, role: both}
`,
"bad repo form": `repos: [notaslash]
personalities:
- {name: a, tea_config: ` + tea + `, role: both}
`,
"no personalities": `repos: [a/b]
`,
"only implementer": `repos: [a/b]
personalities:
- {name: a, tea_config: ` + tea + `, role: implementer}
`,
"only reviewer": `repos: [a/b]
personalities:
- {name: a, tea_config: ` + tea + `, role: reviewer}
`,
"invalid role": `repos: [a/b]
personalities:
- {name: a, tea_config: ` + tea + `, role: bogus}
`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
_, err := Load(writeConfig(t, t.TempDir(), body))
if err == nil {
t.Fatalf("expected error for %q, got nil", name)
}
})
}
}
func TestDuplicatePersonalityNameRejected(t *testing.T) {
dir := t.TempDir()
tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true)
body := `repos: [a/b]
personalities:
- {name: dup, tea_config: ` + tea + `, role: implementer}
- {name: dup, tea_config: ` + tea + `, role: reviewer}
`
if _, err := Load(writeConfig(t, dir, body)); err == nil {
t.Fatal("expected duplicate-name error")
}
}
func TestRoleDefaultsToBoth(t *testing.T) {
dir := t.TempDir()
tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true)
body := `repos: [a/b]
personalities:
- name: solo
tea_config: ` + tea + `
git_name: X
git_email: x@y.z
`
cfg, err := Load(writeConfig(t, dir, body))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Personalities[0].Role != RoleBoth {
t.Errorf("role = %q, want both", cfg.Personalities[0].Role)
}
if !cfg.Personalities[0].CanImplement() || !cfg.Personalities[0].CanReview() {
t.Error("both role should implement and review")
}
}
func TestBotLoginsAndSelectors(t *testing.T) {
cfg := &Config{Personalities: []Personality{
{Name: "i", Role: RoleImplementer, Login: "ibot"},
{Name: "r", Role: RoleReviewer, Login: "rbot"},
}}
logins := cfg.BotLogins()
if !logins["ibot"] || !logins["rbot"] || len(logins) != 2 {
t.Errorf("BotLogins = %v", logins)
}
if p := cfg.ImplementerFor(); p == nil || p.Name != "i" {
t.Errorf("ImplementerFor = %v", p)
}
if p := cfg.ReviewerFor(); p == nil || p.Name != "r" {
t.Errorf("ReviewerFor = %v", p)
}
}
func TestParseTeaConfigPrefersMatchingURL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "multi.yml")
content := `logins:
- name: other
url: https://other.example.com
token: other-tok
default: true
user: otheruser
- name: target
url: https://git.unkin.net
token: target-tok
user: targetuser
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
login, err := ParseTeaConfig(path, "https://git.unkin.net")
if err != nil {
t.Fatalf("ParseTeaConfig: %v", err)
}
if login.User != "targetuser" || login.Token != "target-tok" {
t.Errorf("matched wrong login: %+v", login)
}
}
func TestParseTeaConfigFallsBackToDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "d.yml")
content := `logins:
- name: a
url: https://a.example.com
token: a-tok
user: a
- name: b
url: https://b.example.com
token: b-tok
default: true
user: b
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
login, err := ParseTeaConfig(path, "https://nomatch.example.com")
if err != nil {
t.Fatalf("ParseTeaConfig: %v", err)
}
if login.User != "b" {
t.Errorf("expected default login b, got %q", login.User)
}
}
func TestExampleConfigIsValidWhenTeaConfigsExist(t *testing.T) {
// The shipped example references tea configs by ~/ path; here we just
// verify the example YAML parses into a Config with the expected shape by
// substituting resolvable tea configs.
dir := t.TempDir()
impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok", "implbot", true)
rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok2", "revbot", false)
body := `gitea_url: https://git.unkin.net
repos: [unkin/teabot]
personalities:
- {name: implementer, tea_config: ` + impl + `, role: implementer, git_name: I, git_email: i@x}
- {name: reviewer, tea_config: ` + rev + `, role: reviewer, git_name: R, git_email: r@x}
`
if _, err := Load(writeConfig(t, dir, body)); err != nil {
t.Fatalf("example-shaped config failed to load: %v", err)
}
}
+57
View File
@@ -0,0 +1,57 @@
package config
// Example is a fully-commented sample config written by `teabot config init`.
const Example = `# teabot configuration
# Location: $XDG_CONFIG_HOME/teabot/config.yaml (default ~/.config/teabot/config.yaml)
# Base URL of the Gitea instance to watch.
gitea_url: https://git.unkin.net
# How often to poll each repo.
poll_interval: 60s
# Repositories to watch, in owner/name form.
repos:
- unkin/teabot
# Maximum number of Claude job containers running at once.
max_concurrent: 2
# Per-session wall-clock timeout.
job_timeout: 30m
# Container image each session runs in. The default already ships the Claude
# CLI plus a Go/Node/Python/tea developer toolchain.
job_image: git.unkin.net/unkin/agent-dev:latest
# Home directory inside job_image (mount target for tea/claude config).
container_home: /home/agent
# Host directory holding Claude Code credentials (subscription auth). A private
# copy is mounted into each container so token refreshes never touch this dir.
claude_config_dir: ~/.claude
# Optional: use an Anthropic API key / gateway instead of subscription auth.
# When set these are injected as ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL.
# anthropic_api_key: ""
# anthropic_base_url: ""
# Optional: override the state directory (default ~/.local/state/teabot).
# state_dir: ~/.local/state/teabot
# Bot personalities. Each is a distinct Gitea account backed by its own tea
# config file (create it with: tea logins add --name <bot> ...). teabot reads
# the token + username from that file and mounts it into the container so tea
# acts as this identity. Roles: implementer, reviewer, both.
personalities:
- name: implementer
tea_config: ~/.config/teabot/tea-implementer.yml
role: implementer
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
`
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"os"
"path/filepath"
)
// DefaultConfigPath returns $XDG_CONFIG_HOME/teabot/config.yaml
// (default ~/.config/teabot/config.yaml).
func DefaultConfigPath() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, AppName, "config.yaml")
}
// DefaultStateDir returns $XDG_STATE_HOME/teabot
// (default ~/.local/state/teabot).
func DefaultStateDir() string {
base := os.Getenv("XDG_STATE_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".local", "state")
}
return filepath.Join(base, AppName)
}
// StateDirOrDefault resolves the effective state directory for the config.
func (c *Config) StateDirOrDefault() string {
if c.StateDir != "" {
return c.StateDir
}
return DefaultStateDir()
}
+61
View File
@@ -0,0 +1,61 @@
package config
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// TeaLogin is one entry from a tea config.yml `logins:` list. Only the fields
// teabot needs are modelled; unknown keys are ignored by the YAML decoder.
type TeaLogin struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Default bool `yaml:"default"`
User string `yaml:"user"`
}
// teaConfigFile mirrors the top level of tea's config.yml.
type teaConfigFile struct {
Logins []TeaLogin `yaml:"logins"`
}
// ParseTeaConfig reads a tea config.yml and returns the login teabot should use.
// It prefers a login whose URL matches wantURL, then the default login, then the
// first login. This is the same file format as ~/.config/tea/config.yml.
func ParseTeaConfig(path, wantURL string) (TeaLogin, error) {
data, err := os.ReadFile(path)
if err != nil {
return TeaLogin{}, fmt.Errorf("reading tea config %s: %w", path, err)
}
var f teaConfigFile
if err := yaml.Unmarshal(data, &f); err != nil {
return TeaLogin{}, fmt.Errorf("parsing tea config %s: %w", path, err)
}
if len(f.Logins) == 0 {
return TeaLogin{}, fmt.Errorf("tea config %s has no logins", path)
}
want := strings.TrimRight(wantURL, "/")
var byURL, byDefault *TeaLogin
for i := range f.Logins {
l := &f.Logins[i]
if want != "" && strings.TrimRight(l.URL, "/") == want && byURL == nil {
byURL = l
}
if l.Default && byDefault == nil {
byDefault = l
}
}
switch {
case byURL != nil:
return *byURL, nil
case byDefault != nil:
return *byDefault, nil
default:
return f.Logins[0], nil
}
}
+127
View File
@@ -0,0 +1,127 @@
// Package dispatch is teabot's core loop: it polls watched repos, filters
// events (dedup + loop prevention), and dispatches one-shot Claude sessions.
package dispatch
import (
"context"
"log/slog"
"net/url"
"strings"
"sync"
"time"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// GiteaClient is the read surface of the Gitea API that the dispatcher needs.
// It is an interface so tests can supply a fake without network access.
type GiteaClient interface {
ListIssues(ctx context.Context, repo string, since time.Time) ([]gitea.Issue, error)
ListPulls(ctx context.Context, repo string) ([]gitea.PullRequest, error)
ListComments(ctx context.Context, repo string, since time.Time) ([]gitea.Comment, error)
GetIssueComments(ctx context.Context, repo string, index int64) ([]gitea.Comment, error)
GetIssue(ctx context.Context, repo string, index int64) (gitea.Issue, error)
GetPull(ctx context.Context, repo string, index int64) (gitea.PullRequest, error)
GetPullDiff(ctx context.Context, repo string, index int64) (string, error)
}
// StateStore is the persistence surface the dispatcher needs.
type StateStore interface {
IssueProcessed(repo string, index int64) bool
PullProcessed(repo string, index int64) bool
CommentProcessed(repo string, id int64) bool
ActedOnIssue(repo string, index int64) bool
ActedOnPull(repo string, index int64) bool
MarkIssue(repo string, index int64)
MarkPull(repo string, index int64)
MarkComment(repo string, id int64)
Seeded(repo string) bool
MarkSeeded(repo string)
LastPoll(repo string) time.Time
SetLastPoll(repo string, t time.Time)
Save() error
}
// Dispatcher wires configuration, state, the Gitea client, and the job runner.
type Dispatcher struct {
cfg *config.Config
store StateStore
client GiteaClient
runner docker.Runner
log *slog.Logger
sem chan struct{}
wg sync.WaitGroup
botLogins map[string]bool
gitHost string
}
// New builds a Dispatcher.
func New(cfg *config.Config, store StateStore, client GiteaClient, runner docker.Runner, log *slog.Logger) *Dispatcher {
host := cfg.GiteaURL
if u, err := url.Parse(cfg.GiteaURL); err == nil {
host = u.Host
}
return &Dispatcher{
cfg: cfg,
store: store,
client: client,
runner: runner,
log: log,
sem: make(chan struct{}, cfg.MaxConcurrent),
botLogins: cfg.BotLogins(),
gitHost: host,
}
}
// Run polls on an interval until ctx is cancelled, then waits for in-flight jobs.
func (d *Dispatcher) Run(ctx context.Context) error {
ticker := time.NewTicker(d.cfg.PollInterval)
defer ticker.Stop()
// Poll immediately, then on each tick.
d.pollAll(ctx)
for {
select {
case <-ctx.Done():
d.log.Info("shutting down, waiting for in-flight jobs")
d.wg.Wait()
return d.store.Save()
case <-ticker.C:
d.pollAll(ctx)
}
}
}
// PollOnce runs a single poll cycle. When wait is true it blocks until every
// job dispatched during the cycle has finished (used by `run --once`).
func (d *Dispatcher) PollOnce(ctx context.Context, wait bool) error {
d.pollAll(ctx)
if wait {
d.wg.Wait()
}
return d.store.Save()
}
// Wait blocks until all in-flight jobs finish.
func (d *Dispatcher) Wait() { d.wg.Wait() }
func (d *Dispatcher) pollAll(ctx context.Context) {
for _, repo := range d.cfg.Repos {
if ctx.Err() != nil {
return
}
if err := d.pollRepo(ctx, repo); err != nil {
d.log.Warn("poll failed", "repo", repo, "err", err)
}
}
if err := d.store.Save(); err != nil {
d.log.Warn("saving state failed", "err", err)
}
}
// cloneURL returns the plain HTTPS clone URL for a repo.
func (d *Dispatcher) cloneURL(repo string) string {
return d.cfg.GiteaURL + "/" + strings.Trim(repo, "/") + ".git"
}
+264
View File
@@ -0,0 +1,264 @@
package dispatch
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
"git.unkin.net/unkin/teabot/internal/state"
)
// fakeClient is a scripted GiteaClient. Each field is returned as-is; the
// Get* methods serve follow-up context lookups.
type fakeClient struct {
issues []gitea.Issue
pulls []gitea.PullRequest
comments []gitea.Comment
issueByI map[int64]gitea.Issue
pullByI map[int64]gitea.PullRequest
}
func (f *fakeClient) ListIssues(_ context.Context, _ string, _ time.Time) ([]gitea.Issue, error) {
return f.issues, nil
}
func (f *fakeClient) ListPulls(_ context.Context, _ string) ([]gitea.PullRequest, error) {
return f.pulls, nil
}
func (f *fakeClient) ListComments(_ context.Context, _ string, _ time.Time) ([]gitea.Comment, error) {
return f.comments, nil
}
func (f *fakeClient) GetIssueComments(_ context.Context, _ string, _ int64) ([]gitea.Comment, error) {
return nil, nil
}
func (f *fakeClient) GetIssue(_ context.Context, _ string, index int64) (gitea.Issue, error) {
return f.issueByI[index], nil
}
func (f *fakeClient) GetPull(_ context.Context, _ string, index int64) (gitea.PullRequest, error) {
return f.pullByI[index], nil
}
func (f *fakeClient) GetPullDiff(_ context.Context, _ string, _ int64) (string, error) {
return "diff", nil
}
// recordingRunner captures dispatched jobs; safe for concurrent use.
type recordingRunner struct {
mu sync.Mutex
jobs []docker.Job
}
func (r *recordingRunner) Run(_ context.Context, j docker.Job) (docker.Result, error) {
r.mu.Lock()
r.jobs = append(r.jobs, j)
r.mu.Unlock()
return docker.Result{ExitCode: 0}, nil
}
func (r *recordingRunner) labels() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.jobs))
for i, j := range r.jobs {
out[i] = j.Label
}
return out
}
func testConfig() *config.Config {
return &config.Config{
GiteaURL: "https://git.unkin.net",
Repos: []string{"unkin/teabot"},
PollInterval: time.Second,
MaxConcurrent: 2,
JobTimeout: time.Minute,
JobImage: "img:latest",
ContainerHome: "/home/agent",
Personalities: []config.Personality{
{Name: "impl", Role: config.RoleImplementer, Login: "implbot", Token: "it", TeaConfig: "/x/impl.yml"},
{Name: "rev", Role: config.RoleReviewer, Login: "revbot", Token: "rt", TeaConfig: "/x/rev.yml"},
},
}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func newHarness(t *testing.T, fc *fakeClient) (*Dispatcher, *recordingRunner, *state.Store) {
t.Helper()
store, err := state.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
rr := &recordingRunner{}
d := New(testConfig(), store, fc, rr, discardLogger())
return d, rr, store
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func TestFirstPollSeedsWithoutDispatch(t *testing.T) {
fc := &fakeClient{
issues: []gitea.Issue{{Index: 1, Title: "old", Poster: gitea.User{Login: "human"}}},
pulls: []gitea.PullRequest{{Index: 2, Title: "oldpr", Poster: gitea.User{Login: "human"}}},
comments: []gitea.Comment{{ID: 3, Poster: gitea.User{Login: "human"}}},
}
d, rr, store := newHarness(t, fc)
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
if got := rr.labels(); len(got) != 0 {
t.Errorf("seeding poll dispatched jobs: %v", got)
}
if !store.Seeded("unkin/teabot") {
t.Error("repo not marked seeded")
}
if !store.IssueProcessed("unkin/teabot", 1) || !store.PullProcessed("unkin/teabot", 2) {
t.Error("existing items not recorded during seeding")
}
}
func TestNewIssueDispatchesImplementer(t *testing.T) {
fc := &fakeClient{}
d, rr, store := newHarness(t, fc)
// Seed with empty state.
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
// Now a genuinely new human issue appears.
fc.issues = []gitea.Issue{{Index: 10, Title: "please fix", Poster: gitea.User{Login: "human"}}}
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
if !contains(rr.labels(), "unkin/teabot#issue-10") {
t.Errorf("expected implementer job for issue 10, got %v", rr.labels())
}
// The dispatched job must carry the implementer identity.
if rr.jobs[0].GitUser != "implbot" {
t.Errorf("job dispatched as %q, want implbot", rr.jobs[0].GitUser)
}
if !store.ActedOnIssue("unkin/teabot", 10) {
t.Error("issue 10 should be marked acted-on")
}
// Polling again must NOT re-dispatch (dedup).
before := len(rr.labels())
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
if len(rr.labels()) != before {
t.Errorf("issue re-dispatched: %v", rr.labels())
}
}
func TestBotAuthoredIssueIgnored(t *testing.T) {
fc := &fakeClient{}
d, rr, _ := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true) // seed
fc.issues = []gitea.Issue{{Index: 20, Poster: gitea.User{Login: "implbot"}}}
_ = d.PollOnce(context.Background(), true)
if len(rr.labels()) != 0 {
t.Errorf("bot-authored issue triggered a job: %v", rr.labels())
}
}
func TestNewPullDispatchesReviewer(t *testing.T) {
fc := &fakeClient{}
d, rr, store := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true) // seed
fc.pulls = []gitea.PullRequest{{Index: 30, Title: "add x", Poster: gitea.User{Login: "human"}}}
_ = d.PollOnce(context.Background(), true)
if !contains(rr.labels(), "unkin/teabot#pull-30") {
t.Errorf("expected reviewer job for pull 30, got %v", rr.labels())
}
if rr.jobs[0].GitUser != "revbot" {
t.Errorf("PR job dispatched as %q, want revbot", rr.jobs[0].GitUser)
}
if !store.ActedOnPull("unkin/teabot", 30) {
t.Error("pull 30 should be acted-on")
}
}
func TestCommentFollowUpOnlyOnActedThreads(t *testing.T) {
fc := &fakeClient{
issueByI: map[int64]gitea.Issue{40: {Index: 40, Title: "acted issue"}},
pullByI: map[int64]gitea.PullRequest{50: {Index: 50, Title: "acted pr"}},
}
d, rr, store := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true) // seed
// teabot has acted on issue 40 and pull 50.
store.MarkIssue("unkin/teabot", 40)
store.MarkPull("unkin/teabot", 50)
issueURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/40"
prURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/50"
unactedURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/999"
fc.comments = []gitea.Comment{
{ID: 100, Poster: gitea.User{Login: "human"}, Body: "on acted issue", IssueURL: issueURL},
{ID: 101, Poster: gitea.User{Login: "human"}, Body: "on acted pr", PRURL: prURL},
{ID: 102, Poster: gitea.User{Login: "human"}, Body: "on unacted thread", IssueURL: unactedURL},
{ID: 103, Poster: gitea.User{Login: "implbot"}, Body: "bot comment on acted issue", IssueURL: issueURL},
}
_ = d.PollOnce(context.Background(), true)
labels := rr.labels()
if !contains(labels, "unkin/teabot#issue-40-followup-100") {
t.Errorf("missing issue follow-up: %v", labels)
}
if !contains(labels, "unkin/teabot#pull-50-followup-101") {
t.Errorf("missing pull follow-up: %v", labels)
}
// Comment on an unacted thread must NOT dispatch.
for _, l := range labels {
if l == "unkin/teabot#issue-999-followup-102" {
t.Error("dispatched follow-up for unacted thread")
}
}
// Bot-authored comment (103) must NOT dispatch (loop prevention).
if len(labels) != 2 {
t.Errorf("expected exactly 2 follow-ups (issue+pull), got %v", labels)
}
if !store.CommentProcessed("unkin/teabot", 103) {
t.Error("bot comment should still be recorded as processed")
}
}
func TestFollowUpPersonalityRouting(t *testing.T) {
fc := &fakeClient{
pullByI: map[int64]gitea.PullRequest{60: {Index: 60, Title: "pr"}},
}
d, rr, store := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true)
store.MarkPull("unkin/teabot", 60)
fc.comments = []gitea.Comment{
{ID: 200, Poster: gitea.User{Login: "human"}, Body: "change please",
PRURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/60"},
}
_ = d.PollOnce(context.Background(), true)
if len(rr.jobs) != 1 {
t.Fatalf("expected 1 job, got %d", len(rr.jobs))
}
// A PR follow-up must be handled by the reviewer personality.
if rr.jobs[0].GitUser != "revbot" {
t.Errorf("PR follow-up dispatched as %q, want revbot", rr.jobs[0].GitUser)
}
}
+122
View File
@@ -0,0 +1,122 @@
package dispatch
import (
"context"
"fmt"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
"git.unkin.net/unkin/teabot/internal/prompt"
)
// baseJob fills the personality/repo/runtime fields shared by every job kind.
func (d *Dispatcher) baseJob(repo string, p config.Personality, label, promptText string) docker.Job {
return docker.Job{
Label: label,
Image: d.cfg.JobImage,
ContainerHome: d.cfg.ContainerHome,
Prompt: promptText,
CloneURL: d.cloneURL(repo),
GitHost: d.gitHost,
GitName: p.GitName,
GitEmail: p.GitEmail,
GitUser: p.Login,
Token: p.Token,
TeaConfigPath: p.TeaConfig,
ClaudeConfigDir: d.cfg.ClaudeConfigDir,
AnthropicAPIKey: d.cfg.AnthropicAPIKey,
AnthropicBaseURL: d.cfg.AnthropicBaseURL,
Timeout: d.cfg.JobTimeout,
}
}
func (d *Dispatcher) dispatchIssue(ctx context.Context, repo string, p config.Personality, issue gitea.Issue, comments []gitea.Comment) {
text := prompt.Issue(prompt.IssueContext{
Repo: repo,
PersonalityName: p.Name,
Issue: issue,
Comments: comments,
})
label := fmt.Sprintf("%s#issue-%d", repo, issue.Index)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
func (d *Dispatcher) dispatchPull(ctx context.Context, repo string, p config.Personality, pull gitea.PullRequest, diff string, comments []gitea.Comment) {
text := prompt.Pull(prompt.PullContext{
Repo: repo,
PersonalityName: p.Name,
Pull: pull,
Diff: diff,
Comments: comments,
})
label := fmt.Sprintf("%s#pull-%d", repo, pull.Index)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
func (d *Dispatcher) dispatchIssueFollowUp(ctx context.Context, repo string, p config.Personality, issue gitea.Issue, thread []gitea.Comment, trigger gitea.Comment) {
text := prompt.FollowUp(prompt.FollowUpContext{
Repo: repo,
PersonalityName: p.Name,
Kind: prompt.FollowUpIssue,
Index: issue.Index,
Title: issue.Title,
URL: issue.HTMLURL,
Comments: thread,
NewComment: trigger,
})
label := fmt.Sprintf("%s#issue-%d-followup-%d", repo, issue.Index, trigger.ID)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
func (d *Dispatcher) dispatchPullFollowUp(ctx context.Context, repo string, p config.Personality, pull gitea.PullRequest, thread []gitea.Comment, trigger gitea.Comment) {
text := prompt.FollowUp(prompt.FollowUpContext{
Repo: repo,
PersonalityName: p.Name,
Kind: prompt.FollowUpPull,
Index: pull.Index,
Title: pull.Title,
URL: pull.HTMLURL,
Comments: thread,
NewComment: trigger,
})
label := fmt.Sprintf("%s#pull-%d-followup-%d", repo, pull.Index, trigger.ID)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
// runJob launches a job in a bounded goroutine so at most MaxConcurrent
// containers run at once.
func (d *Dispatcher) runJob(ctx context.Context, job docker.Job) {
d.wg.Add(1)
go func() {
defer d.wg.Done()
select {
case d.sem <- struct{}{}:
defer func() { <-d.sem }()
case <-ctx.Done():
d.log.Warn("cancelled before start", "job", job.Label)
return
}
d.log.Info("dispatching job", "job", job.Label, "image", job.Image)
res, err := d.runner.Run(ctx, job)
if err != nil {
d.log.Error("job failed", "job", job.Label, "err", err, "output", tail(res.Output))
return
}
if res.ExitCode != 0 {
d.log.Warn("job exited non-zero",
"job", job.Label, "exit", res.ExitCode, "duration", res.Duration, "output", tail(res.Output))
return
}
d.log.Info("job completed", "job", job.Label, "duration", res.Duration)
}()
}
// tail returns the last chunk of output for concise error logging.
func tail(s string) string {
const max = 2000
if len(s) <= max {
return s
}
return "..." + s[len(s)-max:]
}
+146
View File
@@ -0,0 +1,146 @@
package dispatch
import (
"context"
"time"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// pollRepo runs one poll cycle for a single repo: it seeds a baseline on first
// contact, then classifies and dispatches new issues, pull requests, and
// comments. Events authored by a bot personality are skipped (loop prevention),
// and anything already recorded in the state store is skipped (dedup).
func (d *Dispatcher) pollRepo(ctx context.Context, repo string) error {
since := d.store.LastPoll(repo)
now := time.Now()
issues, err := d.client.ListIssues(ctx, repo, since)
if err != nil {
return err
}
pulls, err := d.client.ListPulls(ctx, repo)
if err != nil {
return err
}
comments, err := d.client.ListComments(ctx, repo, since)
if err != nil {
return err
}
// First contact: record everything currently open/recent as processed
// without dispatching, so a fresh install doesn't stampede old items.
if !d.store.Seeded(repo) {
for _, i := range issues {
d.store.MarkIssue(repo, i.Index)
}
for _, p := range pulls {
d.store.MarkPull(repo, p.Index)
}
for _, c := range comments {
d.store.MarkComment(repo, c.ID)
}
d.store.MarkSeeded(repo)
d.store.SetLastPoll(repo, now)
d.log.Info("seeded repo baseline",
"repo", repo, "issues", len(issues), "pulls", len(pulls), "comments", len(comments))
return nil
}
for _, issue := range issues {
d.handleIssue(ctx, repo, issue)
}
for _, pull := range pulls {
d.handlePull(ctx, repo, pull)
}
for _, comment := range comments {
d.handleComment(ctx, repo, comment)
}
d.store.SetLastPoll(repo, now)
return nil
}
// handleIssue dispatches an implementer session for a genuinely new issue.
func (d *Dispatcher) handleIssue(ctx context.Context, repo string, issue gitea.Issue) {
if d.store.IssueProcessed(repo, issue.Index) {
return
}
if d.botLogins[issue.Poster.Login] {
d.store.MarkIssue(repo, issue.Index) // remember, but never act on our own
return
}
p := d.cfg.ImplementerFor()
if p == nil {
return
}
// Record before dispatch so a duplicate poll cannot double-launch.
d.store.MarkIssue(repo, issue.Index)
comments, _ := d.client.GetIssueComments(ctx, repo, issue.Index)
d.dispatchIssue(ctx, repo, *p, issue, comments)
}
// handlePull dispatches a reviewer session for a genuinely new pull request.
func (d *Dispatcher) handlePull(ctx context.Context, repo string, pull gitea.PullRequest) {
if d.store.PullProcessed(repo, pull.Index) {
return
}
if d.botLogins[pull.Poster.Login] {
d.store.MarkPull(repo, pull.Index)
return
}
p := d.cfg.ReviewerFor()
if p == nil {
return
}
d.store.MarkPull(repo, pull.Index)
diff, _ := d.client.GetPullDiff(ctx, repo, pull.Index)
comments, _ := d.client.GetIssueComments(ctx, repo, pull.Index)
d.dispatchPull(ctx, repo, *p, pull, diff, comments)
}
// handleComment dispatches a follow-up session for a new comment on a thread
// teabot previously acted on.
func (d *Dispatcher) handleComment(ctx context.Context, repo string, comment gitea.Comment) {
if d.store.CommentProcessed(repo, comment.ID) {
return
}
// Always record the comment so it is not reconsidered next cycle.
d.store.MarkComment(repo, comment.ID)
if d.botLogins[comment.Poster.Login] {
return // loop prevention: never react to our own comments
}
index, ok := gitea.IssueIndexFromCommentURL(comment)
if !ok {
return
}
switch {
case d.store.ActedOnPull(repo, index):
p := d.cfg.ReviewerFor()
if p == nil {
return
}
pull, err := d.client.GetPull(ctx, repo, index)
if err != nil {
d.log.Warn("fetching pull for follow-up failed", "repo", repo, "index", index, "err", err)
return
}
thread, _ := d.client.GetIssueComments(ctx, repo, index)
d.dispatchPullFollowUp(ctx, repo, *p, pull, thread, comment)
case d.store.ActedOnIssue(repo, index):
p := d.cfg.ImplementerFor()
if p == nil {
return
}
issue, err := d.client.GetIssue(ctx, repo, index)
if err != nil {
d.log.Warn("fetching issue for follow-up failed", "repo", repo, "index", index, "err", err)
return
}
thread, _ := d.client.GetIssueComments(ctx, repo, index)
d.dispatchIssueFollowUp(ctx, repo, *p, issue, thread, comment)
default:
// Comment on a thread teabot never engaged with: ignore.
}
}
+271
View File
@@ -0,0 +1,271 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
// jobScript is the entrypoint executed inside the container. It configures the
// git identity and credentials, clones the target repo, and runs Claude in
// non-interactive print mode reading the prompt from a mounted file. All inputs
// arrive via environment variables so the script itself is static.
const jobScript = `#!/usr/bin/env bash
set -euo pipefail
export HOME="${TEABOT_HOME}"
mkdir -p "$HOME/.config"
git config --global user.name "${TEABOT_GIT_NAME}"
git config --global user.email "${TEABOT_GIT_EMAIL}"
git config --global credential.helper store
git config --global init.defaultBranch main
umask 077
printf 'https://%s:%s@%s\n' "${TEABOT_GIT_USER}" "${TEABOT_TOKEN}" "${TEABOT_GIT_HOST}" > "$HOME/.git-credentials"
WORK="$HOME/work"
mkdir -p "$WORK"
cd "$WORK"
echo "teabot: cloning ${TEABOT_CLONE_URL}"
git clone --quiet "${TEABOT_CLONE_URL}" repo
cd repo
echo "teabot: starting claude session"
claude --print --dangerously-skip-permissions < /teabot/prompt.txt
`
// DockerRunner runs jobs with the local docker CLI.
type DockerRunner struct {
// DockerPath is the docker binary (default "docker").
DockerPath string
// SELinuxLabel is the volume relabel suffix. On Fedora this must be "z"
// (shared) or "Z" (private) so bind mounts are accessible under SELinux.
SELinuxLabel string
// WorkRoot is where per-job scratch directories are created
// (default os.TempDir()).
WorkRoot string
// Stdout receives streamed container output (nil discards the stream; the
// captured output is always returned in Result regardless).
Stdout io.Writer
}
// NewDockerRunner builds a runner with sensible defaults for this host.
func NewDockerRunner() *DockerRunner {
return &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
}
// Run implements Runner.
func (r *DockerRunner) Run(ctx context.Context, job Job) (Result, error) {
if job.Image == "" {
return Result{}, errors.New("job image is empty")
}
jobDir, err := r.prepareJobDir(job)
if err != nil {
return Result{}, err
}
defer os.RemoveAll(jobDir)
if job.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, job.Timeout)
defer cancel()
}
args := r.buildArgs(job, jobDir)
start := time.Now()
cmd := exec.CommandContext(ctx, r.DockerPath, args...)
var buf bytes.Buffer
if r.Stdout != nil {
cmd.Stdout = io.MultiWriter(&buf, r.Stdout)
cmd.Stderr = io.MultiWriter(&buf, r.Stdout)
} else {
cmd.Stdout = &buf
cmd.Stderr = &buf
}
runErr := cmd.Run()
res := Result{Output: buf.String(), Duration: time.Since(start)}
if ctx.Err() == context.DeadlineExceeded {
res.TimedOut = true
res.ExitCode = -1
return res, fmt.Errorf("job %q timed out after %s", job.Label, job.Timeout)
}
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res, nil
}
if runErr != nil {
return res, fmt.Errorf("running docker: %w", runErr)
}
res.ExitCode = 0
return res, nil
}
// prepareJobDir materialises the mounted files for a job: the prompt, the job
// script, a private copy of the Claude config (so token refreshes never touch
// the host's real config), and a copy of the personality's tea config.
func (r *DockerRunner) prepareJobDir(job Job) (string, error) {
root := r.WorkRoot
if root == "" {
root = os.TempDir()
}
if err := os.MkdirAll(root, 0o700); err != nil {
return "", err
}
jobDir, err := os.MkdirTemp(root, "teabot-job-")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(jobDir, "prompt.txt"), []byte(job.Prompt), 0o600); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(jobDir, "job.sh"), []byte(jobScript), 0o700); err != nil {
return "", err
}
// Copy the Claude config dir so the container can refresh subscription
// tokens without mutating the host's ~/.claude.
if job.ClaudeConfigDir != "" {
if _, statErr := os.Stat(job.ClaudeConfigDir); statErr == nil {
if err := copyTree(job.ClaudeConfigDir, filepath.Join(jobDir, "claude")); err != nil {
return "", fmt.Errorf("copying claude config: %w", err)
}
}
}
// Copy the personality's tea config to the mounted XDG location.
if job.TeaConfigPath != "" {
teaDir := filepath.Join(jobDir, "tea")
if err := os.MkdirAll(teaDir, 0o700); err != nil {
return "", err
}
if err := copyFile(job.TeaConfigPath, filepath.Join(teaDir, "config.yml")); err != nil {
return "", fmt.Errorf("copying tea config: %w", err)
}
}
return jobDir, nil
}
// buildArgs assembles the full `docker run` argument list for a job. It is pure
// (given jobDir) so it can be unit-tested without invoking docker.
func (r *DockerRunner) buildArgs(job Job, jobDir string) []string {
home := job.ContainerHome
if home == "" {
home = "/home/agent"
}
label := r.SELinuxLabel
mount := func(host, container string, ro bool) string {
spec := host + ":" + container
if ro {
spec += ":ro"
if label != "" {
spec += "," + label
}
} else if label != "" {
spec += ":" + label
}
return spec
}
args := []string{"run", "--rm", "--entrypoint", "/bin/bash"}
// Mount the job scratch (prompt + script) read-only.
args = append(args, "-v", mount(filepath.Join(jobDir, "prompt.txt"), "/teabot/prompt.txt", true))
args = append(args, "-v", mount(filepath.Join(jobDir, "job.sh"), "/teabot/job.sh", true))
// Mount the private Claude config copy read-write (token refresh).
if job.ClaudeConfigDir != "" {
args = append(args, "-v", mount(filepath.Join(jobDir, "claude"), home+"/.claude", false))
}
// Mount the tea config read-only at the XDG path.
if job.TeaConfigPath != "" {
args = append(args, "-v", mount(filepath.Join(jobDir, "tea", "config.yml"), home+"/.config/tea/config.yml", true))
}
// Environment consumed by job.sh.
env := map[string]string{
"TEABOT_HOME": home,
"TEABOT_GIT_NAME": job.GitName,
"TEABOT_GIT_EMAIL": job.GitEmail,
"TEABOT_GIT_USER": job.GitUser,
"TEABOT_TOKEN": job.Token,
"TEABOT_GIT_HOST": job.GitHost,
"TEABOT_CLONE_URL": job.CloneURL,
"XDG_CONFIG_HOME": home + "/.config",
}
if job.AnthropicAPIKey != "" {
env["ANTHROPIC_API_KEY"] = job.AnthropicAPIKey
}
if job.AnthropicBaseURL != "" {
env["ANTHROPIC_BASE_URL"] = job.AnthropicBaseURL
}
for _, k := range sortedKeys(env) {
args = append(args, "-e", k+"="+env[k])
}
args = append(args, job.Image, "/teabot/job.sh")
return args
}
// sortedKeys returns map keys in deterministic order (stable docker args ease
// testing and logging).
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// simple insertion sort avoids importing sort for a tiny map
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
keys[j-1], keys[j] = keys[j], keys[j-1]
}
}
return keys
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return nil
}
// copyTree recursively copies a directory tree (regular files, dirs, and
// symlink targets are dereferenced by copyFile via Open).
func copyTree(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o700)
}
if !info.Mode().IsRegular() {
return nil // skip sockets/devices; symlinks are followed by Walk's lstat -> handle below
}
return copyFile(path, target)
})
}
+161
View File
@@ -0,0 +1,161 @@
package docker
import (
"context"
"strings"
"testing"
"time"
)
// argsString joins docker args for easy substring assertions.
func argsString(a []string) string { return strings.Join(a, " ") }
// hasFlagValue reports whether args contains flag immediately followed by value.
func hasFlagValue(args []string, flag, value string) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] == flag && args[i+1] == value {
return true
}
}
return false
}
func baseJob() Job {
return Job{
Label: "unkin/teabot#issue-1",
Image: "git.unkin.net/unkin/agent-dev:latest",
ContainerHome: "/home/agent",
Prompt: "do the thing",
CloneURL: "https://git.unkin.net/unkin/teabot.git",
GitHost: "git.unkin.net",
GitName: "Impl Bot",
GitEmail: "impl@unkin.net",
GitUser: "implbot",
Token: "secret-token",
TeaConfigPath: "/home/ben/.config/teabot/tea-impl.yml",
ClaudeConfigDir: "/home/ben/.claude",
}
}
func TestBuildArgsCoreShape(t *testing.T) {
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/job123")
s := argsString(args)
if args[0] != "run" {
t.Errorf("first arg = %q, want run", args[0])
}
if !hasFlagValue(args, "--entrypoint", "/bin/bash") {
t.Error("missing --entrypoint /bin/bash")
}
if !strings.Contains(s, "--rm") {
t.Error("missing --rm")
}
// Image and job script must be the trailing args.
if args[len(args)-2] != "git.unkin.net/unkin/agent-dev:latest" || args[len(args)-1] != "/teabot/job.sh" {
t.Errorf("trailing args = %v", args[len(args)-2:])
}
}
func TestBuildArgsMountsWithSELinuxLabel(t *testing.T) {
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/job123")
s := argsString(args)
// Prompt + job script mounted read-only with the SELinux relabel.
if !strings.Contains(s, "/tmp/job123/prompt.txt:/teabot/prompt.txt:ro,z") {
t.Error("prompt mount missing or wrong flags")
}
if !strings.Contains(s, "/tmp/job123/job.sh:/teabot/job.sh:ro,z") {
t.Error("job.sh mount missing or wrong flags")
}
// Claude config copy mounted read-write with relabel.
if !strings.Contains(s, "/tmp/job123/claude:/home/agent/.claude:z") {
t.Error("claude mount missing or wrong flags")
}
// Tea config mounted read-only at the XDG path.
if !strings.Contains(s, "/tmp/job123/tea/config.yml:/home/agent/.config/tea/config.yml:ro,z") {
t.Error("tea config mount missing or wrong flags")
}
}
func TestBuildArgsInjectsGitAndXDGEnv(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/j")
checks := map[string]string{
"TEABOT_GIT_USER": "implbot",
"TEABOT_TOKEN": "secret-token",
"TEABOT_GIT_HOST": "git.unkin.net",
"TEABOT_CLONE_URL": "https://git.unkin.net/unkin/teabot.git",
"TEABOT_HOME": "/home/agent",
"XDG_CONFIG_HOME": "/home/agent/.config",
}
for k, v := range checks {
if !hasFlagValue(args, "-e", k+"="+v) {
t.Errorf("missing env -e %s=%s", k, v)
}
}
}
func TestBuildArgsAnthropicEnvOptIn(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
// Without keys, no ANTHROPIC_* env should be injected.
args := r.buildArgs(baseJob(), "/tmp/j")
if strings.Contains(argsString(args), "ANTHROPIC_API_KEY") {
t.Error("ANTHROPIC_API_KEY injected when unset")
}
// With keys, both are injected.
j := baseJob()
j.AnthropicAPIKey = "sk-test"
j.AnthropicBaseURL = "https://gw.example.com"
args = r.buildArgs(j, "/tmp/j")
if !hasFlagValue(args, "-e", "ANTHROPIC_API_KEY=sk-test") {
t.Error("missing ANTHROPIC_API_KEY env")
}
if !hasFlagValue(args, "-e", "ANTHROPIC_BASE_URL=https://gw.example.com") {
t.Error("missing ANTHROPIC_BASE_URL env")
}
}
func TestBuildArgsOmitsClaudeMountWhenUnset(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
j := baseJob()
j.ClaudeConfigDir = ""
j.TeaConfigPath = ""
s := argsString(r.buildArgs(j, "/tmp/j"))
if strings.Contains(s, ".claude") {
t.Error("claude mount present despite empty ClaudeConfigDir")
}
if strings.Contains(s, "tea/config.yml") {
t.Error("tea mount present despite empty TeaConfigPath")
}
}
func TestJobScriptIsBash(t *testing.T) {
if !strings.HasPrefix(jobScript, "#!/usr/bin/env bash") {
t.Error("job script missing bash shebang")
}
for _, needed := range []string{"git clone", "claude --print", "credential.helper store", "/teabot/prompt.txt"} {
if !strings.Contains(jobScript, needed) {
t.Errorf("job script missing %q", needed)
}
}
}
// fakeRunner demonstrates the Runner interface is satisfiable without docker.
type fakeRunner struct{ jobs []Job }
func (f *fakeRunner) Run(_ context.Context, j Job) (Result, error) {
f.jobs = append(f.jobs, j)
return Result{ExitCode: 0, Duration: time.Millisecond}, nil
}
func TestRunnerInterfaceSatisfiedByFake(t *testing.T) {
var r Runner = &fakeRunner{}
res, err := r.Run(context.Background(), baseJob())
if err != nil || res.ExitCode != 0 {
t.Fatalf("fake runner: res=%+v err=%v", res, err)
}
}
+64
View File
@@ -0,0 +1,64 @@
// Package docker runs a one-shot Claude Code session inside a container. The
// Runner interface keeps dispatch logic testable without a real Docker daemon.
package docker
import (
"context"
"time"
)
// Job fully describes a single containerised Claude session.
type Job struct {
// Label is a short identifier used for logging and the job directory name.
Label string
// Image is the container image to run.
Image string
// ContainerHome is the home directory inside Image (mount target root).
ContainerHome string
// Prompt is the full instruction handed to `claude --print`.
Prompt string
// CloneURL is the plain HTTPS clone URL of the repo to work in
// (e.g. https://git.unkin.net/unkin/teabot.git). Auth is supplied via a
// git credential store built from Token, never embedded in this URL.
CloneURL string
// GitHost is the host used for the credential store entry (e.g. git.unkin.net).
GitHost string
// GitName / GitEmail set the container's commit identity.
GitName string
GitEmail string
// GitUser is the bot's Gitea username (credential store user).
GitUser string
// Token is the bot's Gitea token, used for git push and (indirectly) tea.
Token string
// TeaConfigPath is the host path to the personality's tea config.yml,
// mounted so tea acts as this identity inside the container.
TeaConfigPath string
// ClaudeConfigDir is the host directory holding Claude Code credentials.
ClaudeConfigDir string
// AnthropicAPIKey / AnthropicBaseURL, when set, are injected as env vars
// instead of relying on the mounted subscription credentials.
AnthropicAPIKey string
AnthropicBaseURL string
// Timeout bounds the session.
Timeout time.Duration
}
// Result captures the outcome of a job.
type Result struct {
ExitCode int
Output string
Duration time.Duration
// TimedOut is true when the job was killed for exceeding Timeout.
TimedOut bool
}
// Runner executes jobs. DockerRunner is the production implementation; tests
// substitute a fake.
type Runner interface {
Run(ctx context.Context, job Job) (Result, error)
}
+224
View File
@@ -0,0 +1,224 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client is an authenticated Gitea REST client scoped to a single token.
type Client struct {
baseURL string
token string
http *http.Client
}
// NewClient builds a client for baseURL (e.g. https://git.unkin.net) using the
// given API token.
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// SetHTTPClient overrides the underlying HTTP client (used in tests).
func (c *Client) SetHTTPClient(h *http.Client) { c.http = h }
func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error {
u := c.baseURL + "/api/v1" + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decoding %s response: %w", path, err)
}
return nil
}
// splitRepo splits "owner/name" into its parts.
func splitRepo(repo string) (owner, name string, err error) {
parts := strings.SplitN(strings.Trim(repo, "/"), "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid repo %q, want owner/name", repo)
}
return parts[0], parts[1], nil
}
// ListIssues returns open issues (excluding pull requests) updated since the
// given time. A zero time returns all open issues.
func (c *Client) ListIssues(ctx context.Context, repo string, since time.Time) ([]Issue, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("type", "issues")
q.Set("state", "open")
q.Set("limit", "50")
if !since.IsZero() {
q.Set("since", since.UTC().Format(time.RFC3339))
}
var issues []Issue
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues", owner, name), q, &issues); err != nil {
return nil, err
}
// Defensive: the API should exclude PRs given type=issues, but drop any
// that slip through.
out := issues[:0]
for _, i := range issues {
if !i.IsPull() {
out = append(out, i)
}
}
return out, nil
}
// ListPulls returns open pull requests for a repo, most-recently-updated first.
func (c *Client) ListPulls(ctx context.Context, repo string) ([]PullRequest, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("state", "open")
q.Set("sort", "recentupdate")
q.Set("limit", "50")
var pulls []PullRequest
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/pulls", owner, name), q, &pulls); err != nil {
return nil, err
}
return pulls, nil
}
// ListComments returns issue/PR comments across a repo updated since the given
// time. Gitea's repo-level comments endpoint covers both issues and PRs.
func (c *Client) ListComments(ctx context.Context, repo string, since time.Time) ([]Comment, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("limit", "50")
if !since.IsZero() {
q.Set("since", since.UTC().Format(time.RFC3339))
}
var comments []Comment
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues/comments", owner, name), q, &comments); err != nil {
return nil, err
}
return comments, nil
}
// GetIssueComments returns all comments on a single issue or PR (by index),
// used to build follow-up thread context.
func (c *Client) GetIssueComments(ctx context.Context, repo string, index int64) ([]Comment, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
var comments []Comment
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, name, index)
if err := c.get(ctx, path, nil, &comments); err != nil {
return nil, err
}
return comments, nil
}
// GetIssue fetches a single issue (or PR, which Gitea also serves here) by index.
func (c *Client) GetIssue(ctx context.Context, repo string, index int64) (Issue, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return Issue{}, err
}
var issue Issue
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, name, index)
if err := c.get(ctx, path, nil, &issue); err != nil {
return Issue{}, err
}
return issue, nil
}
// GetPull fetches a single pull request by index.
func (c *Client) GetPull(ctx context.Context, repo string, index int64) (PullRequest, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return PullRequest{}, err
}
var pr PullRequest
path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, name, index)
if err := c.get(ctx, path, nil, &pr); err != nil {
return PullRequest{}, err
}
return pr, nil
}
// GetPullDiff fetches the unified diff of a pull request for review context.
func (c *Client) GetPullDiff(ctx context.Context, repo string, index int64) (string, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return "", err
}
u := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, name, index)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("GET pull diff: status %d", resp.StatusCode)
}
return string(body), nil
}
// IssueIndexFromCommentURL extracts the issue/PR index from a comment's
// issue_url or pull_request_url (e.g. ".../issues/42" -> 42).
func IssueIndexFromCommentURL(c Comment) (int64, bool) {
raw := c.IssueURL
if raw == "" {
raw = c.PRURL
}
if raw == "" {
return 0, false
}
parts := strings.Split(strings.TrimRight(raw, "/"), "/")
if len(parts) == 0 {
return 0, false
}
n, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return 0, false
}
return n, true
}
+135
View File
@@ -0,0 +1,135 @@
package gitea
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newTestClient(t *testing.T, h http.Handler) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := NewClient(srv.URL, "test-token")
return c
}
func TestListIssuesExcludesPullsAndSendsAuth(t *testing.T) {
var gotAuth, gotType, gotState, gotSince string
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotType = r.URL.Query().Get("type")
gotState = r.URL.Query().Get("state")
gotSince = r.URL.Query().Get("since")
w.Header().Set("Content-Type", "application/json")
// One real issue and one PR-shaped issue that must be filtered out.
_, _ = w.Write([]byte(`[
{"id":1,"number":5,"title":"real issue","user":{"login":"alice"}},
{"id":2,"number":6,"title":"a pr","user":{"login":"bob"},"pull_request":{"merged":false}}
]`))
}))
since := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
issues, err := c.ListIssues(context.Background(), "unkin/teabot", since)
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if len(issues) != 1 || issues[0].Index != 5 {
t.Fatalf("expected 1 non-PR issue #5, got %+v", issues)
}
if gotAuth != "token test-token" {
t.Errorf("Authorization = %q", gotAuth)
}
if gotType != "issues" || gotState != "open" {
t.Errorf("query type=%q state=%q", gotType, gotState)
}
if gotSince != "2026-01-02T03:04:05Z" {
t.Errorf("since = %q", gotSince)
}
}
func TestListPulls(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != "open" {
t.Errorf("state = %q", r.URL.Query().Get("state"))
}
_, _ = w.Write([]byte(`[{"id":1,"number":7,"title":"add feature","user":{"login":"carol"},"head":{"ref":"benvin/x"},"base":{"ref":"main"}}]`))
}))
pulls, err := c.ListPulls(context.Background(), "unkin/teabot")
if err != nil {
t.Fatalf("ListPulls: %v", err)
}
if len(pulls) != 1 || pulls[0].Index != 7 || pulls[0].Head.Ref != "benvin/x" {
t.Fatalf("unexpected pulls: %+v", pulls)
}
}
func TestListComments(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":42,"body":"looks good","user":{"login":"dave"},"issue_url":"https://git.unkin.net/api/v1/repos/unkin/teabot/issues/5"}]`))
}))
comments, err := c.ListComments(context.Background(), "unkin/teabot", time.Time{})
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(comments) != 1 || comments[0].ID != 42 {
t.Fatalf("unexpected comments: %+v", comments)
}
}
func TestGetPullDiff(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/unkin/teabot/pulls/7.diff" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte("diff --git a/x b/x\n+hello\n"))
}))
diff, err := c.GetPullDiff(context.Background(), "unkin/teabot", 7)
if err != nil {
t.Fatalf("GetPullDiff: %v", err)
}
if diff == "" || diff[:4] != "diff" {
t.Errorf("unexpected diff: %q", diff)
}
}
func TestGetErrorsOnNon2xx(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusForbidden)
}))
if _, err := c.ListIssues(context.Background(), "a/b", time.Time{}); err == nil {
t.Error("expected error on 403")
}
}
func TestSplitRepoValidation(t *testing.T) {
c := NewClient("https://example.com", "t")
if _, err := c.ListIssues(context.Background(), "noslash", time.Time{}); err == nil {
t.Error("expected error for repo without slash")
}
}
func TestIssueIndexFromCommentURL(t *testing.T) {
cases := []struct {
name string
comment Comment
want int64
ok bool
}{
{"issue url", Comment{IssueURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/5"}, 5, true},
{"pr url", Comment{PRURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/12"}, 12, true},
{"trailing slash", Comment{IssueURL: "https://x/issues/8/"}, 8, true},
{"no url", Comment{}, 0, false},
{"non-numeric", Comment{IssueURL: "https://x/issues/abc"}, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := IssueIndexFromCommentURL(tc.comment)
if ok != tc.ok || got != tc.want {
t.Errorf("got (%d,%v), want (%d,%v)", got, ok, tc.want, tc.ok)
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package gitea is a small read-mostly client for the Gitea REST API covering
// the endpoints teabot needs: listing issues, pull requests, and comments.
package gitea
import "time"
// User is the subset of a Gitea user teabot cares about.
type User struct {
Login string `json:"login"`
ID int64 `json:"id"`
}
// Issue represents a Gitea issue. Gitea's issues endpoint also returns pull
// requests; the PullRequest field is non-nil for those.
type Issue struct {
ID int64 `json:"id"`
Index int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Poster User `json:"user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
PullRequest *PullRequestRef `json:"pull_request,omitempty"`
}
// IsPull reports whether this issue is actually a pull request.
func (i Issue) IsPull() bool { return i.PullRequest != nil }
// PullRequestRef is the marker Gitea attaches to issues that are PRs.
type PullRequestRef struct {
Merged bool `json:"merged"`
}
// PullRequest represents a Gitea pull request.
type PullRequest struct {
ID int64 `json:"id"`
Index int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Poster User `json:"user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
Head Branch `json:"head"`
Base Branch `json:"base"`
}
// Branch is one side of a pull request.
type Branch struct {
Ref string `json:"ref"`
Sha string `json:"sha"`
}
// Comment is a comment on an issue or pull request.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
Poster User `json:"user"`
Created time.Time `json:"created_at"`
Updated time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
IssueURL string `json:"issue_url"`
PRURL string `json:"pull_request_url"`
}
+163
View File
@@ -0,0 +1,163 @@
// Package prompt builds the task-specific instructions handed to a one-shot
// Claude Code session for each kind of Gitea event.
package prompt
import (
"fmt"
"strings"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// conventions is appended to every prompt so sessions follow the house rules
// (branch naming, PR body shape, and the loop-safety expectation of finishing
// in one pass).
const conventions = `Conventions you MUST follow:
- Work on a branch named benvin/<short-descriptive-name>; never push to the default branch.
- Use HTTPS remotes for git.unkin.net (SSH is blocked). The clone is already authenticated.
- PR descriptions have a short "why" paragraph followed by present-tense "how" bullets.
- Use the tea CLI (already configured for your bot identity) for Gitea actions such as creating PRs and posting comments; never use gh.
- This is a single non-interactive session: complete the task in one pass, then stop. Do not wait for input.`
// IssueContext carries everything needed to prompt an implementer session for a
// newly opened issue.
type IssueContext struct {
Repo string
PersonalityName string
Issue gitea.Issue
Comments []gitea.Comment
}
// Issue builds the prompt for reviewing and possibly implementing an issue.
func Issue(c IssueContext) string {
var b strings.Builder
fmt.Fprintf(&b, "You are %q, an autonomous implementer bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new issue was opened. Review it and decide whether it warrants a code change.\n")
fmt.Fprintf(&b, "If it does, implement the change on a new branch and open a pull request that links the issue with \"Closes #%d\".\n", c.Issue.Index)
fmt.Fprintf(&b, "If it does NOT warrant a code change (question, discussion, invalid, needs clarification), post a brief comment on the issue explaining your assessment instead of opening a PR.\n\n")
fmt.Fprintf(&b, "Issue #%d: %s\n", c.Issue.Index, c.Issue.Title)
fmt.Fprintf(&b, "Opened by: %s\n", c.Issue.Poster.Login)
fmt.Fprintf(&b, "URL: %s\n\n", c.Issue.HTMLURL)
b.WriteString("Issue body:\n")
b.WriteString(bodyOrNone(c.Issue.Body))
b.WriteString("\n")
writeComments(&b, c.Comments)
b.WriteString("\n")
b.WriteString(conventions)
return b.String()
}
// PullContext carries everything needed to prompt a reviewer session for a PR.
type PullContext struct {
Repo string
PersonalityName string
Pull gitea.PullRequest
Diff string
Comments []gitea.Comment
}
// Pull builds the prompt for reviewing a pull request.
func Pull(c PullContext) string {
var b strings.Builder
fmt.Fprintf(&b, "You are %q, an autonomous reviewer bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new pull request was opened. Review it thoroughly and decide whether the change is correct and ready.\n")
b.WriteString("Judge: does the diff actually satisfy the PR description? Is it correct, tested, and consistent with the codebase's style?\n")
fmt.Fprintf(&b, "Post your review on PR #%d using tea: an approving review if it is good, or a review requesting changes with specific, actionable comments if not.\n\n", c.Pull.Index)
fmt.Fprintf(&b, "PR #%d: %s\n", c.Pull.Index, c.Pull.Title)
fmt.Fprintf(&b, "Opened by: %s\n", c.Pull.Poster.Login)
fmt.Fprintf(&b, "Branch: %s -> %s\n", c.Pull.Head.Ref, c.Pull.Base.Ref)
fmt.Fprintf(&b, "URL: %s\n\n", c.Pull.HTMLURL)
b.WriteString("PR description:\n")
b.WriteString(bodyOrNone(c.Pull.Body))
b.WriteString("\n")
writeComments(&b, c.Comments)
if strings.TrimSpace(c.Diff) != "" {
b.WriteString("\nUnified diff:\n")
b.WriteString(truncate(c.Diff, 60000))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(conventions)
return b.String()
}
// FollowUpKind distinguishes a follow-up on an issue vs a pull request.
type FollowUpKind string
const (
// FollowUpIssue is a new comment on an issue teabot acted on.
FollowUpIssue FollowUpKind = "issue"
// FollowUpPull is a new comment on a pull request teabot acted on.
FollowUpPull FollowUpKind = "pull"
)
// FollowUpContext carries everything needed to prompt a follow-up session for a
// new comment on a thread teabot previously engaged with.
type FollowUpContext struct {
Repo string
PersonalityName string
Kind FollowUpKind
Index int64
Title string
URL string
Comments []gitea.Comment // full thread, oldest first
NewComment gitea.Comment // the comment that triggered this follow-up
}
// FollowUp builds the prompt for responding to a new comment.
func FollowUp(c FollowUpContext) string {
var b strings.Builder
noun := "issue"
if c.Kind == FollowUpPull {
noun = "pull request"
}
fmt.Fprintf(&b, "You are %q, an autonomous bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new comment was posted on %s #%d, a thread you previously worked on. Read the full thread and respond or take action as appropriate.\n", noun, c.Index)
if c.Kind == FollowUpPull {
b.WriteString("If the comment requests changes, check out the PR branch, make the changes, and push them; then reply summarising what you did.\n")
} else {
b.WriteString("If the comment asks for a change or clarifies the request, implement it on a branch and open or update the PR; otherwise reply with a helpful comment.\n")
}
fmt.Fprintf(&b, "\n%s #%d: %s\n", strings.ToUpper(noun[:1])+noun[1:], c.Index, c.Title)
fmt.Fprintf(&b, "URL: %s\n\n", c.URL)
b.WriteString("Full thread (oldest first):\n")
writeComments(&b, c.Comments)
fmt.Fprintf(&b, "\nThe new comment that triggered this task was posted by %s:\n", c.NewComment.Poster.Login)
b.WriteString(bodyOrNone(c.NewComment.Body))
b.WriteString("\n\n")
b.WriteString(conventions)
return b.String()
}
func writeComments(b *strings.Builder, comments []gitea.Comment) {
if len(comments) == 0 {
return
}
b.WriteString("\nComments (oldest first):\n")
for _, c := range comments {
fmt.Fprintf(b, "- %s: %s\n", c.Poster.Login, oneLine(c.Body))
}
}
func bodyOrNone(s string) string {
if strings.TrimSpace(s) == "" {
return "(no description provided)"
}
return s
}
func oneLine(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n... [diff truncated] ..."
}
+98
View File
@@ -0,0 +1,98 @@
package prompt
import (
"strings"
"testing"
"git.unkin.net/unkin/teabot/internal/gitea"
)
func TestIssuePromptContainsContextAndConventions(t *testing.T) {
p := Issue(IssueContext{
Repo: "unkin/teabot",
PersonalityName: "implementer",
Issue: gitea.Issue{
Index: 42,
Title: "Add a flag",
Body: "Please add --verbose",
Poster: gitea.User{Login: "alice"},
HTMLURL: "https://git.unkin.net/unkin/teabot/issues/42",
},
Comments: []gitea.Comment{{Poster: gitea.User{Login: "bob"}, Body: "agreed"}},
})
mustContain(t, p, "implementer")
mustContain(t, p, "unkin/teabot")
mustContain(t, p, "Add a flag")
mustContain(t, p, "Please add --verbose")
mustContain(t, p, "Closes #42") // must instruct linking the issue
mustContain(t, p, "bob: agreed")
mustContain(t, p, "benvin/") // branch-naming convention
mustContain(t, p, "present-tense")
}
func TestIssuePromptHandlesEmptyBody(t *testing.T) {
p := Issue(IssueContext{
Repo: "a/b",
Issue: gitea.Issue{Index: 1, Title: "t"},
})
mustContain(t, p, "(no description provided)")
}
func TestPullPromptIncludesDiffAndReviewInstruction(t *testing.T) {
p := Pull(PullContext{
Repo: "unkin/teabot",
PersonalityName: "reviewer",
Pull: gitea.PullRequest{
Index: 7,
Title: "Implement thing",
Body: "does the thing",
Poster: gitea.User{Login: "carol"},
Head: gitea.Branch{Ref: "benvin/thing"},
Base: gitea.Branch{Ref: "main"},
},
Diff: "diff --git a/x b/x\n+added line\n",
})
mustContain(t, p, "reviewer")
mustContain(t, p, "PR #7")
mustContain(t, p, "does the diff actually satisfy")
mustContain(t, p, "benvin/thing -> main")
mustContain(t, p, "diff --git a/x b/x")
mustContain(t, p, "+added line")
}
func TestPullPromptTruncatesHugeDiff(t *testing.T) {
huge := strings.Repeat("x", 70000)
p := Pull(PullContext{Repo: "a/b", Pull: gitea.PullRequest{Index: 1}, Diff: huge})
mustContain(t, p, "[diff truncated]")
if len(p) > 70000 {
t.Errorf("prompt not truncated, len=%d", len(p))
}
}
func TestFollowUpIssueVsPullWording(t *testing.T) {
issueThread := []gitea.Comment{{Poster: gitea.User{Login: "a"}, Body: "first"}}
trigger := gitea.Comment{Poster: gitea.User{Login: "human"}, Body: "please tweak it"}
ip := FollowUp(FollowUpContext{
Repo: "a/b", Kind: FollowUpIssue, Index: 3, Title: "T",
URL: "u", Comments: issueThread, NewComment: trigger,
})
mustContain(t, ip, "issue #3")
mustContain(t, ip, "please tweak it")
mustContain(t, ip, "posted by human")
pp := FollowUp(FollowUpContext{
Repo: "a/b", Kind: FollowUpPull, Index: 4, Title: "T2",
URL: "u", Comments: issueThread, NewComment: trigger,
})
mustContain(t, pp, "pull request #4")
mustContain(t, pp, "check out the PR branch")
}
func mustContain(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("prompt missing %q\n---\n%s", needle, haystack)
}
}
+219
View File
@@ -0,0 +1,219 @@
// Package state persists which Gitea events teabot has already handled so a
// restart does not re-trigger work. State is a single JSON file under the
// configured state directory (default ~/.local/state/teabot/state.json).
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// StateFileName is the JSON file holding processed-event bookkeeping.
const StateFileName = "state.json"
// RepoState tracks what has been handled for one repository.
type RepoState struct {
// ProcessedIssues holds issue indexes already dispatched.
ProcessedIssues map[int64]bool `json:"processed_issues"`
// ProcessedPulls holds pull-request indexes already dispatched.
ProcessedPulls map[int64]bool `json:"processed_pulls"`
// ProcessedComments holds comment IDs already dispatched.
ProcessedComments map[int64]bool `json:"processed_comments"`
// ActedIssues/ActedPulls record which issues/PRs teabot ran a session
// for, so comment follow-ups only fire on threads the bot engaged with.
ActedIssues map[int64]bool `json:"acted_issues"`
ActedPulls map[int64]bool `json:"acted_pulls"`
// Seeded is set the first time a repo is polled: existing open issues/PRs
// and recent comments are recorded as processed WITHOUT dispatching, so a
// fresh install does not stampede every open item.
Seeded bool `json:"seeded"`
// LastPoll is the time of the last completed poll, used to bound `since`
// queries on subsequent cycles.
LastPoll time.Time `json:"last_poll"`
}
func newRepoState() *RepoState {
return &RepoState{
ProcessedIssues: map[int64]bool{},
ProcessedPulls: map[int64]bool{},
ProcessedComments: map[int64]bool{},
ActedIssues: map[int64]bool{},
ActedPulls: map[int64]bool{},
}
}
// data is the on-disk document.
type data struct {
Repos map[string]*RepoState `json:"repos"`
}
// Store is a thread-safe, file-backed processed-event tracker.
type Store struct {
path string
mu sync.Mutex
d *data
}
// New loads the store from dir, creating an empty one if the file is absent.
func New(dir string) (*Store, error) {
s := &Store{
path: filepath.Join(dir, StateFileName),
d: &data{Repos: map[string]*RepoState{}},
}
raw, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, fmt.Errorf("reading state %s: %w", s.path, err)
}
if len(raw) == 0 {
return s, nil
}
if err := json.Unmarshal(raw, s.d); err != nil {
return nil, fmt.Errorf("parsing state %s: %w", s.path, err)
}
if s.d.Repos == nil {
s.d.Repos = map[string]*RepoState{}
}
return s, nil
}
// repo returns the RepoState for repo, creating it if needed. Caller holds mu.
func (s *Store) repo(repo string) *RepoState {
rs := s.d.Repos[repo]
if rs == nil {
rs = newRepoState()
s.d.Repos[repo] = rs
}
// Guard against a partially-populated document loaded from disk.
if rs.ProcessedIssues == nil {
rs.ProcessedIssues = map[int64]bool{}
}
if rs.ProcessedPulls == nil {
rs.ProcessedPulls = map[int64]bool{}
}
if rs.ProcessedComments == nil {
rs.ProcessedComments = map[int64]bool{}
}
if rs.ActedIssues == nil {
rs.ActedIssues = map[int64]bool{}
}
if rs.ActedPulls == nil {
rs.ActedPulls = map[int64]bool{}
}
return rs
}
// IssueProcessed reports whether an issue index was already handled.
func (s *Store) IssueProcessed(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ProcessedIssues[index]
}
// PullProcessed reports whether a PR index was already handled.
func (s *Store) PullProcessed(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ProcessedPulls[index]
}
// CommentProcessed reports whether a comment ID was already handled.
func (s *Store) CommentProcessed(repo string, id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ProcessedComments[id]
}
// ActedOnIssue reports whether teabot ran a session for an issue.
func (s *Store) ActedOnIssue(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ActedIssues[index]
}
// ActedOnPull reports whether teabot ran a session for a PR.
func (s *Store) ActedOnPull(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ActedPulls[index]
}
// MarkIssue records an issue index as processed and acted-on.
func (s *Store) MarkIssue(repo string, index int64) {
s.mu.Lock()
rs := s.repo(repo)
rs.ProcessedIssues[index] = true
rs.ActedIssues[index] = true
s.mu.Unlock()
}
// MarkPull records a PR index as processed and acted-on.
func (s *Store) MarkPull(repo string, index int64) {
s.mu.Lock()
rs := s.repo(repo)
rs.ProcessedPulls[index] = true
rs.ActedPulls[index] = true
s.mu.Unlock()
}
// MarkComment records a comment ID as processed.
func (s *Store) MarkComment(repo string, id int64) {
s.mu.Lock()
s.repo(repo).ProcessedComments[id] = true
s.mu.Unlock()
}
// Seeded reports whether a repo has completed its baseline seeding poll.
func (s *Store) Seeded(repo string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).Seeded
}
// MarkSeeded records that a repo has completed baseline seeding.
func (s *Store) MarkSeeded(repo string) {
s.mu.Lock()
s.repo(repo).Seeded = true
s.mu.Unlock()
}
// LastPoll returns the time of the last completed poll for a repo.
func (s *Store) LastPoll(repo string) time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).LastPoll
}
// SetLastPoll records the time of the last completed poll for a repo.
func (s *Store) SetLastPoll(repo string, t time.Time) {
s.mu.Lock()
s.repo(repo).LastPoll = t
s.mu.Unlock()
}
// Save atomically writes the state document to disk.
func (s *Store) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("creating state dir: %w", err)
}
raw, err := json.MarshalIndent(s.d, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return fmt.Errorf("writing state: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("committing state: %w", err)
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package state
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestMarkAndQuery(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
const repo = "unkin/teabot"
if s.IssueProcessed(repo, 1) {
t.Error("fresh store should not report issue 1 processed")
}
s.MarkIssue(repo, 1)
if !s.IssueProcessed(repo, 1) {
t.Error("issue 1 should be processed after MarkIssue")
}
if !s.ActedOnIssue(repo, 1) {
t.Error("MarkIssue should also record acted-on")
}
if s.ActedOnPull(repo, 1) {
t.Error("issue mark must not set acted-on-pull")
}
s.MarkPull(repo, 2)
if !s.PullProcessed(repo, 2) || !s.ActedOnPull(repo, 2) {
t.Error("pull 2 should be processed and acted-on")
}
s.MarkComment(repo, 99)
if !s.CommentProcessed(repo, 99) {
t.Error("comment 99 should be processed")
}
if s.CommentProcessed(repo, 100) {
t.Error("comment 100 was never marked")
}
}
func TestPersistenceRoundTrip(t *testing.T) {
dir := t.TempDir()
s1, err := New(dir)
if err != nil {
t.Fatal(err)
}
const repo = "a/b"
s1.MarkIssue(repo, 10)
s1.MarkPull(repo, 11)
s1.MarkComment(repo, 12)
s1.MarkSeeded(repo)
now := time.Now().Truncate(time.Second)
s1.SetLastPoll(repo, now)
if err := s1.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
// A fresh Store loaded from the same dir must see the persisted state.
s2, err := New(dir)
if err != nil {
t.Fatal(err)
}
if !s2.IssueProcessed(repo, 10) || !s2.PullProcessed(repo, 11) || !s2.CommentProcessed(repo, 12) {
t.Error("processed sets did not survive reload")
}
if !s2.ActedOnIssue(repo, 10) || !s2.ActedOnPull(repo, 11) {
t.Error("acted-on sets did not survive reload")
}
if !s2.Seeded(repo) {
t.Error("seeded flag did not survive reload")
}
if !s2.LastPoll(repo).Equal(now) {
t.Errorf("LastPoll = %v, want %v", s2.LastPoll(repo), now)
}
}
func TestSaveIsAtomicFile(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
s.MarkIssue("a/b", 1)
if err := s.Save(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, StateFileName)); err != nil {
t.Errorf("state file missing after Save: %v", err)
}
// No leftover temp file.
if _, err := os.Stat(filepath.Join(dir, StateFileName+".tmp")); !os.IsNotExist(err) {
t.Error("temp file should not remain after atomic rename")
}
}
func TestSeededIndependentPerRepo(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
s.MarkSeeded("a/b")
if s.Seeded("c/d") {
t.Error("seeding a/b must not seed c/d")
}
if !s.Seeded("a/b") {
t.Error("a/b should be seeded")
}
}
func TestLoadCorruptStateFails(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, StateFileName), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New(dir); err == nil {
t.Error("expected error loading corrupt state file")
}
}
+13
View File
@@ -0,0 +1,13 @@
// Command teabot is a daemon that watches Gitea repositories and dispatches
// one-shot Claude Code sessions in Docker containers to work issues and review
// pull requests.
package main
import "git.unkin.net/unkin/teabot/internal/cli"
// version is overridden at build time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
cli.Execute(version)
}
+58
View File
@@ -0,0 +1,58 @@
---
# nfpm config for building the teabot RPM.
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
name: ${PACKAGE_NAME}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "${PACKAGE_DESCRIPTION}"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
replaces:
- teabot
provides:
- teabot
contents:
# The daemon binary.
- src: dist/teabot
dst: /usr/bin/teabot
file_info:
mode: 0755
owner: root
group: root
# systemd user unit (enable with: systemctl --user enable --now teabot).
- src: systemd/teabot.service
dst: /usr/lib/systemd/user/teabot.service
file_info:
mode: 0644
# Example config, installed as documentation.
- src: config.example.yaml
dst: /usr/share/doc/teabot/config.example.yaml
file_info:
mode: 0644
# Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/teabot.bash
dst: /usr/share/bash-completion/completions/teabot
file_info:
mode: 0644
- src: dist/completions/_teabot
dst: /usr/share/zsh/site-functions/_teabot
file_info:
mode: 0644
- src: dist/completions/teabot.fish
dst: /usr/share/fish/vendor_completions.d/teabot.fish
file_info:
mode: 0644
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# Package the (already built) teabot binary into an RPM with nfpm, bundling
# generated bash/zsh/fish shell completions, the systemd user unit, and the
# example config.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
BINARY="teabot"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
# Generate shell completions from the freshly built binary so they always match
# the shipped flags/subcommands.
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
export PACKAGE_NAME="${BINARY}"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="A daemon that watches Gitea repos and dispatches one-shot Claude Code sessions in Docker to work issues and review PRs"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/teabot"
export PACKAGE_LICENSE="MIT"
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
echo "Built:"
ls -1 "${DIST}"/*.rpm
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=teabot - Gitea watcher dispatching one-shot Claude Code sessions
Documentation=https://git.unkin.net/unkin/teabot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Run in the foreground; teabot logs to stderr which the journal captures.
ExecStart=/usr/bin/teabot run
Restart=on-failure
RestartSec=10
# teabot shells out to docker; keep the user's environment (PATH, DOCKER_HOST).
Environment=XDG_CONFIG_HOME=%h/.config
Environment=XDG_STATE_HOME=%h/.local/state
[Install]
WantedBy=default.target