Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in Docker containers to work issues and review PRs, acting as configurable bot personalities. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user