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,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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user