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
+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
}
}