Files
teabot/internal/cli/run.go
T
unkinben 748048be50
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add fail-closed author allowlist gating job dispatch
Sessions run claude with --dangerously-skip-permissions and a prompt built
from issue/PR/comment text, so only trusted authors may supply that text.
teabot now dispatches a job only when the triggering event's author login is
on an allowlist; an empty allowlist dispatches nothing (fail-closed).

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 00:33:09 +10:00

102 lines
2.7 KiB
Go

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
}
if !cfg.HasAnyAllowlist() {
logger.Warn("no allowed_authors configured; teabot is fail-closed and will dispatch NOTHING until an allowlist is set")
}
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)
}