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