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)
}
+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
}
}
+127
View File
@@ -0,0 +1,127 @@
// Package dispatch is teabot's core loop: it polls watched repos, filters
// events (dedup + loop prevention), and dispatches one-shot Claude sessions.
package dispatch
import (
"context"
"log/slog"
"net/url"
"strings"
"sync"
"time"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// GiteaClient is the read surface of the Gitea API that the dispatcher needs.
// It is an interface so tests can supply a fake without network access.
type GiteaClient interface {
ListIssues(ctx context.Context, repo string, since time.Time) ([]gitea.Issue, error)
ListPulls(ctx context.Context, repo string) ([]gitea.PullRequest, error)
ListComments(ctx context.Context, repo string, since time.Time) ([]gitea.Comment, error)
GetIssueComments(ctx context.Context, repo string, index int64) ([]gitea.Comment, error)
GetIssue(ctx context.Context, repo string, index int64) (gitea.Issue, error)
GetPull(ctx context.Context, repo string, index int64) (gitea.PullRequest, error)
GetPullDiff(ctx context.Context, repo string, index int64) (string, error)
}
// StateStore is the persistence surface the dispatcher needs.
type StateStore interface {
IssueProcessed(repo string, index int64) bool
PullProcessed(repo string, index int64) bool
CommentProcessed(repo string, id int64) bool
ActedOnIssue(repo string, index int64) bool
ActedOnPull(repo string, index int64) bool
MarkIssue(repo string, index int64)
MarkPull(repo string, index int64)
MarkComment(repo string, id int64)
Seeded(repo string) bool
MarkSeeded(repo string)
LastPoll(repo string) time.Time
SetLastPoll(repo string, t time.Time)
Save() error
}
// Dispatcher wires configuration, state, the Gitea client, and the job runner.
type Dispatcher struct {
cfg *config.Config
store StateStore
client GiteaClient
runner docker.Runner
log *slog.Logger
sem chan struct{}
wg sync.WaitGroup
botLogins map[string]bool
gitHost string
}
// New builds a Dispatcher.
func New(cfg *config.Config, store StateStore, client GiteaClient, runner docker.Runner, log *slog.Logger) *Dispatcher {
host := cfg.GiteaURL
if u, err := url.Parse(cfg.GiteaURL); err == nil {
host = u.Host
}
return &Dispatcher{
cfg: cfg,
store: store,
client: client,
runner: runner,
log: log,
sem: make(chan struct{}, cfg.MaxConcurrent),
botLogins: cfg.BotLogins(),
gitHost: host,
}
}
// Run polls on an interval until ctx is cancelled, then waits for in-flight jobs.
func (d *Dispatcher) Run(ctx context.Context) error {
ticker := time.NewTicker(d.cfg.PollInterval)
defer ticker.Stop()
// Poll immediately, then on each tick.
d.pollAll(ctx)
for {
select {
case <-ctx.Done():
d.log.Info("shutting down, waiting for in-flight jobs")
d.wg.Wait()
return d.store.Save()
case <-ticker.C:
d.pollAll(ctx)
}
}
}
// PollOnce runs a single poll cycle. When wait is true it blocks until every
// job dispatched during the cycle has finished (used by `run --once`).
func (d *Dispatcher) PollOnce(ctx context.Context, wait bool) error {
d.pollAll(ctx)
if wait {
d.wg.Wait()
}
return d.store.Save()
}
// Wait blocks until all in-flight jobs finish.
func (d *Dispatcher) Wait() { d.wg.Wait() }
func (d *Dispatcher) pollAll(ctx context.Context) {
for _, repo := range d.cfg.Repos {
if ctx.Err() != nil {
return
}
if err := d.pollRepo(ctx, repo); err != nil {
d.log.Warn("poll failed", "repo", repo, "err", err)
}
}
if err := d.store.Save(); err != nil {
d.log.Warn("saving state failed", "err", err)
}
}
// cloneURL returns the plain HTTPS clone URL for a repo.
func (d *Dispatcher) cloneURL(repo string) string {
return d.cfg.GiteaURL + "/" + strings.Trim(repo, "/") + ".git"
}
+264
View File
@@ -0,0 +1,264 @@
package dispatch
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
"git.unkin.net/unkin/teabot/internal/state"
)
// fakeClient is a scripted GiteaClient. Each field is returned as-is; the
// Get* methods serve follow-up context lookups.
type fakeClient struct {
issues []gitea.Issue
pulls []gitea.PullRequest
comments []gitea.Comment
issueByI map[int64]gitea.Issue
pullByI map[int64]gitea.PullRequest
}
func (f *fakeClient) ListIssues(_ context.Context, _ string, _ time.Time) ([]gitea.Issue, error) {
return f.issues, nil
}
func (f *fakeClient) ListPulls(_ context.Context, _ string) ([]gitea.PullRequest, error) {
return f.pulls, nil
}
func (f *fakeClient) ListComments(_ context.Context, _ string, _ time.Time) ([]gitea.Comment, error) {
return f.comments, nil
}
func (f *fakeClient) GetIssueComments(_ context.Context, _ string, _ int64) ([]gitea.Comment, error) {
return nil, nil
}
func (f *fakeClient) GetIssue(_ context.Context, _ string, index int64) (gitea.Issue, error) {
return f.issueByI[index], nil
}
func (f *fakeClient) GetPull(_ context.Context, _ string, index int64) (gitea.PullRequest, error) {
return f.pullByI[index], nil
}
func (f *fakeClient) GetPullDiff(_ context.Context, _ string, _ int64) (string, error) {
return "diff", nil
}
// recordingRunner captures dispatched jobs; safe for concurrent use.
type recordingRunner struct {
mu sync.Mutex
jobs []docker.Job
}
func (r *recordingRunner) Run(_ context.Context, j docker.Job) (docker.Result, error) {
r.mu.Lock()
r.jobs = append(r.jobs, j)
r.mu.Unlock()
return docker.Result{ExitCode: 0}, nil
}
func (r *recordingRunner) labels() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.jobs))
for i, j := range r.jobs {
out[i] = j.Label
}
return out
}
func testConfig() *config.Config {
return &config.Config{
GiteaURL: "https://git.unkin.net",
Repos: []string{"unkin/teabot"},
PollInterval: time.Second,
MaxConcurrent: 2,
JobTimeout: time.Minute,
JobImage: "img:latest",
ContainerHome: "/home/agent",
Personalities: []config.Personality{
{Name: "impl", Role: config.RoleImplementer, Login: "implbot", Token: "it", TeaConfig: "/x/impl.yml"},
{Name: "rev", Role: config.RoleReviewer, Login: "revbot", Token: "rt", TeaConfig: "/x/rev.yml"},
},
}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func newHarness(t *testing.T, fc *fakeClient) (*Dispatcher, *recordingRunner, *state.Store) {
t.Helper()
store, err := state.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
rr := &recordingRunner{}
d := New(testConfig(), store, fc, rr, discardLogger())
return d, rr, store
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func TestFirstPollSeedsWithoutDispatch(t *testing.T) {
fc := &fakeClient{
issues: []gitea.Issue{{Index: 1, Title: "old", Poster: gitea.User{Login: "human"}}},
pulls: []gitea.PullRequest{{Index: 2, Title: "oldpr", Poster: gitea.User{Login: "human"}}},
comments: []gitea.Comment{{ID: 3, Poster: gitea.User{Login: "human"}}},
}
d, rr, store := newHarness(t, fc)
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
if got := rr.labels(); len(got) != 0 {
t.Errorf("seeding poll dispatched jobs: %v", got)
}
if !store.Seeded("unkin/teabot") {
t.Error("repo not marked seeded")
}
if !store.IssueProcessed("unkin/teabot", 1) || !store.PullProcessed("unkin/teabot", 2) {
t.Error("existing items not recorded during seeding")
}
}
func TestNewIssueDispatchesImplementer(t *testing.T) {
fc := &fakeClient{}
d, rr, store := newHarness(t, fc)
// Seed with empty state.
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
// Now a genuinely new human issue appears.
fc.issues = []gitea.Issue{{Index: 10, Title: "please fix", Poster: gitea.User{Login: "human"}}}
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
if !contains(rr.labels(), "unkin/teabot#issue-10") {
t.Errorf("expected implementer job for issue 10, got %v", rr.labels())
}
// The dispatched job must carry the implementer identity.
if rr.jobs[0].GitUser != "implbot" {
t.Errorf("job dispatched as %q, want implbot", rr.jobs[0].GitUser)
}
if !store.ActedOnIssue("unkin/teabot", 10) {
t.Error("issue 10 should be marked acted-on")
}
// Polling again must NOT re-dispatch (dedup).
before := len(rr.labels())
if err := d.PollOnce(context.Background(), true); err != nil {
t.Fatal(err)
}
if len(rr.labels()) != before {
t.Errorf("issue re-dispatched: %v", rr.labels())
}
}
func TestBotAuthoredIssueIgnored(t *testing.T) {
fc := &fakeClient{}
d, rr, _ := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true) // seed
fc.issues = []gitea.Issue{{Index: 20, Poster: gitea.User{Login: "implbot"}}}
_ = d.PollOnce(context.Background(), true)
if len(rr.labels()) != 0 {
t.Errorf("bot-authored issue triggered a job: %v", rr.labels())
}
}
func TestNewPullDispatchesReviewer(t *testing.T) {
fc := &fakeClient{}
d, rr, store := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true) // seed
fc.pulls = []gitea.PullRequest{{Index: 30, Title: "add x", Poster: gitea.User{Login: "human"}}}
_ = d.PollOnce(context.Background(), true)
if !contains(rr.labels(), "unkin/teabot#pull-30") {
t.Errorf("expected reviewer job for pull 30, got %v", rr.labels())
}
if rr.jobs[0].GitUser != "revbot" {
t.Errorf("PR job dispatched as %q, want revbot", rr.jobs[0].GitUser)
}
if !store.ActedOnPull("unkin/teabot", 30) {
t.Error("pull 30 should be acted-on")
}
}
func TestCommentFollowUpOnlyOnActedThreads(t *testing.T) {
fc := &fakeClient{
issueByI: map[int64]gitea.Issue{40: {Index: 40, Title: "acted issue"}},
pullByI: map[int64]gitea.PullRequest{50: {Index: 50, Title: "acted pr"}},
}
d, rr, store := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true) // seed
// teabot has acted on issue 40 and pull 50.
store.MarkIssue("unkin/teabot", 40)
store.MarkPull("unkin/teabot", 50)
issueURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/40"
prURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/50"
unactedURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/999"
fc.comments = []gitea.Comment{
{ID: 100, Poster: gitea.User{Login: "human"}, Body: "on acted issue", IssueURL: issueURL},
{ID: 101, Poster: gitea.User{Login: "human"}, Body: "on acted pr", PRURL: prURL},
{ID: 102, Poster: gitea.User{Login: "human"}, Body: "on unacted thread", IssueURL: unactedURL},
{ID: 103, Poster: gitea.User{Login: "implbot"}, Body: "bot comment on acted issue", IssueURL: issueURL},
}
_ = d.PollOnce(context.Background(), true)
labels := rr.labels()
if !contains(labels, "unkin/teabot#issue-40-followup-100") {
t.Errorf("missing issue follow-up: %v", labels)
}
if !contains(labels, "unkin/teabot#pull-50-followup-101") {
t.Errorf("missing pull follow-up: %v", labels)
}
// Comment on an unacted thread must NOT dispatch.
for _, l := range labels {
if l == "unkin/teabot#issue-999-followup-102" {
t.Error("dispatched follow-up for unacted thread")
}
}
// Bot-authored comment (103) must NOT dispatch (loop prevention).
if len(labels) != 2 {
t.Errorf("expected exactly 2 follow-ups (issue+pull), got %v", labels)
}
if !store.CommentProcessed("unkin/teabot", 103) {
t.Error("bot comment should still be recorded as processed")
}
}
func TestFollowUpPersonalityRouting(t *testing.T) {
fc := &fakeClient{
pullByI: map[int64]gitea.PullRequest{60: {Index: 60, Title: "pr"}},
}
d, rr, store := newHarness(t, fc)
_ = d.PollOnce(context.Background(), true)
store.MarkPull("unkin/teabot", 60)
fc.comments = []gitea.Comment{
{ID: 200, Poster: gitea.User{Login: "human"}, Body: "change please",
PRURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/60"},
}
_ = d.PollOnce(context.Background(), true)
if len(rr.jobs) != 1 {
t.Fatalf("expected 1 job, got %d", len(rr.jobs))
}
// A PR follow-up must be handled by the reviewer personality.
if rr.jobs[0].GitUser != "revbot" {
t.Errorf("PR follow-up dispatched as %q, want revbot", rr.jobs[0].GitUser)
}
}
+122
View File
@@ -0,0 +1,122 @@
package dispatch
import (
"context"
"fmt"
"git.unkin.net/unkin/teabot/internal/config"
"git.unkin.net/unkin/teabot/internal/docker"
"git.unkin.net/unkin/teabot/internal/gitea"
"git.unkin.net/unkin/teabot/internal/prompt"
)
// baseJob fills the personality/repo/runtime fields shared by every job kind.
func (d *Dispatcher) baseJob(repo string, p config.Personality, label, promptText string) docker.Job {
return docker.Job{
Label: label,
Image: d.cfg.JobImage,
ContainerHome: d.cfg.ContainerHome,
Prompt: promptText,
CloneURL: d.cloneURL(repo),
GitHost: d.gitHost,
GitName: p.GitName,
GitEmail: p.GitEmail,
GitUser: p.Login,
Token: p.Token,
TeaConfigPath: p.TeaConfig,
ClaudeConfigDir: d.cfg.ClaudeConfigDir,
AnthropicAPIKey: d.cfg.AnthropicAPIKey,
AnthropicBaseURL: d.cfg.AnthropicBaseURL,
Timeout: d.cfg.JobTimeout,
}
}
func (d *Dispatcher) dispatchIssue(ctx context.Context, repo string, p config.Personality, issue gitea.Issue, comments []gitea.Comment) {
text := prompt.Issue(prompt.IssueContext{
Repo: repo,
PersonalityName: p.Name,
Issue: issue,
Comments: comments,
})
label := fmt.Sprintf("%s#issue-%d", repo, issue.Index)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
func (d *Dispatcher) dispatchPull(ctx context.Context, repo string, p config.Personality, pull gitea.PullRequest, diff string, comments []gitea.Comment) {
text := prompt.Pull(prompt.PullContext{
Repo: repo,
PersonalityName: p.Name,
Pull: pull,
Diff: diff,
Comments: comments,
})
label := fmt.Sprintf("%s#pull-%d", repo, pull.Index)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
func (d *Dispatcher) dispatchIssueFollowUp(ctx context.Context, repo string, p config.Personality, issue gitea.Issue, thread []gitea.Comment, trigger gitea.Comment) {
text := prompt.FollowUp(prompt.FollowUpContext{
Repo: repo,
PersonalityName: p.Name,
Kind: prompt.FollowUpIssue,
Index: issue.Index,
Title: issue.Title,
URL: issue.HTMLURL,
Comments: thread,
NewComment: trigger,
})
label := fmt.Sprintf("%s#issue-%d-followup-%d", repo, issue.Index, trigger.ID)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
func (d *Dispatcher) dispatchPullFollowUp(ctx context.Context, repo string, p config.Personality, pull gitea.PullRequest, thread []gitea.Comment, trigger gitea.Comment) {
text := prompt.FollowUp(prompt.FollowUpContext{
Repo: repo,
PersonalityName: p.Name,
Kind: prompt.FollowUpPull,
Index: pull.Index,
Title: pull.Title,
URL: pull.HTMLURL,
Comments: thread,
NewComment: trigger,
})
label := fmt.Sprintf("%s#pull-%d-followup-%d", repo, pull.Index, trigger.ID)
d.runJob(ctx, d.baseJob(repo, p, label, text))
}
// runJob launches a job in a bounded goroutine so at most MaxConcurrent
// containers run at once.
func (d *Dispatcher) runJob(ctx context.Context, job docker.Job) {
d.wg.Add(1)
go func() {
defer d.wg.Done()
select {
case d.sem <- struct{}{}:
defer func() { <-d.sem }()
case <-ctx.Done():
d.log.Warn("cancelled before start", "job", job.Label)
return
}
d.log.Info("dispatching job", "job", job.Label, "image", job.Image)
res, err := d.runner.Run(ctx, job)
if err != nil {
d.log.Error("job failed", "job", job.Label, "err", err, "output", tail(res.Output))
return
}
if res.ExitCode != 0 {
d.log.Warn("job exited non-zero",
"job", job.Label, "exit", res.ExitCode, "duration", res.Duration, "output", tail(res.Output))
return
}
d.log.Info("job completed", "job", job.Label, "duration", res.Duration)
}()
}
// tail returns the last chunk of output for concise error logging.
func tail(s string) string {
const max = 2000
if len(s) <= max {
return s
}
return "..." + s[len(s)-max:]
}
+146
View File
@@ -0,0 +1,146 @@
package dispatch
import (
"context"
"time"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// pollRepo runs one poll cycle for a single repo: it seeds a baseline on first
// contact, then classifies and dispatches new issues, pull requests, and
// comments. Events authored by a bot personality are skipped (loop prevention),
// and anything already recorded in the state store is skipped (dedup).
func (d *Dispatcher) pollRepo(ctx context.Context, repo string) error {
since := d.store.LastPoll(repo)
now := time.Now()
issues, err := d.client.ListIssues(ctx, repo, since)
if err != nil {
return err
}
pulls, err := d.client.ListPulls(ctx, repo)
if err != nil {
return err
}
comments, err := d.client.ListComments(ctx, repo, since)
if err != nil {
return err
}
// First contact: record everything currently open/recent as processed
// without dispatching, so a fresh install doesn't stampede old items.
if !d.store.Seeded(repo) {
for _, i := range issues {
d.store.MarkIssue(repo, i.Index)
}
for _, p := range pulls {
d.store.MarkPull(repo, p.Index)
}
for _, c := range comments {
d.store.MarkComment(repo, c.ID)
}
d.store.MarkSeeded(repo)
d.store.SetLastPoll(repo, now)
d.log.Info("seeded repo baseline",
"repo", repo, "issues", len(issues), "pulls", len(pulls), "comments", len(comments))
return nil
}
for _, issue := range issues {
d.handleIssue(ctx, repo, issue)
}
for _, pull := range pulls {
d.handlePull(ctx, repo, pull)
}
for _, comment := range comments {
d.handleComment(ctx, repo, comment)
}
d.store.SetLastPoll(repo, now)
return nil
}
// handleIssue dispatches an implementer session for a genuinely new issue.
func (d *Dispatcher) handleIssue(ctx context.Context, repo string, issue gitea.Issue) {
if d.store.IssueProcessed(repo, issue.Index) {
return
}
if d.botLogins[issue.Poster.Login] {
d.store.MarkIssue(repo, issue.Index) // remember, but never act on our own
return
}
p := d.cfg.ImplementerFor()
if p == nil {
return
}
// Record before dispatch so a duplicate poll cannot double-launch.
d.store.MarkIssue(repo, issue.Index)
comments, _ := d.client.GetIssueComments(ctx, repo, issue.Index)
d.dispatchIssue(ctx, repo, *p, issue, comments)
}
// handlePull dispatches a reviewer session for a genuinely new pull request.
func (d *Dispatcher) handlePull(ctx context.Context, repo string, pull gitea.PullRequest) {
if d.store.PullProcessed(repo, pull.Index) {
return
}
if d.botLogins[pull.Poster.Login] {
d.store.MarkPull(repo, pull.Index)
return
}
p := d.cfg.ReviewerFor()
if p == nil {
return
}
d.store.MarkPull(repo, pull.Index)
diff, _ := d.client.GetPullDiff(ctx, repo, pull.Index)
comments, _ := d.client.GetIssueComments(ctx, repo, pull.Index)
d.dispatchPull(ctx, repo, *p, pull, diff, comments)
}
// handleComment dispatches a follow-up session for a new comment on a thread
// teabot previously acted on.
func (d *Dispatcher) handleComment(ctx context.Context, repo string, comment gitea.Comment) {
if d.store.CommentProcessed(repo, comment.ID) {
return
}
// Always record the comment so it is not reconsidered next cycle.
d.store.MarkComment(repo, comment.ID)
if d.botLogins[comment.Poster.Login] {
return // loop prevention: never react to our own comments
}
index, ok := gitea.IssueIndexFromCommentURL(comment)
if !ok {
return
}
switch {
case d.store.ActedOnPull(repo, index):
p := d.cfg.ReviewerFor()
if p == nil {
return
}
pull, err := d.client.GetPull(ctx, repo, index)
if err != nil {
d.log.Warn("fetching pull for follow-up failed", "repo", repo, "index", index, "err", err)
return
}
thread, _ := d.client.GetIssueComments(ctx, repo, index)
d.dispatchPullFollowUp(ctx, repo, *p, pull, thread, comment)
case d.store.ActedOnIssue(repo, index):
p := d.cfg.ImplementerFor()
if p == nil {
return
}
issue, err := d.client.GetIssue(ctx, repo, index)
if err != nil {
d.log.Warn("fetching issue for follow-up failed", "repo", repo, "index", index, "err", err)
return
}
thread, _ := d.client.GetIssueComments(ctx, repo, index)
d.dispatchIssueFollowUp(ctx, repo, *p, issue, thread, comment)
default:
// Comment on a thread teabot never engaged with: ignore.
}
}
+271
View File
@@ -0,0 +1,271 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
// jobScript is the entrypoint executed inside the container. It configures the
// git identity and credentials, clones the target repo, and runs Claude in
// non-interactive print mode reading the prompt from a mounted file. All inputs
// arrive via environment variables so the script itself is static.
const jobScript = `#!/usr/bin/env bash
set -euo pipefail
export HOME="${TEABOT_HOME}"
mkdir -p "$HOME/.config"
git config --global user.name "${TEABOT_GIT_NAME}"
git config --global user.email "${TEABOT_GIT_EMAIL}"
git config --global credential.helper store
git config --global init.defaultBranch main
umask 077
printf 'https://%s:%s@%s\n' "${TEABOT_GIT_USER}" "${TEABOT_TOKEN}" "${TEABOT_GIT_HOST}" > "$HOME/.git-credentials"
WORK="$HOME/work"
mkdir -p "$WORK"
cd "$WORK"
echo "teabot: cloning ${TEABOT_CLONE_URL}"
git clone --quiet "${TEABOT_CLONE_URL}" repo
cd repo
echo "teabot: starting claude session"
claude --print --dangerously-skip-permissions < /teabot/prompt.txt
`
// DockerRunner runs jobs with the local docker CLI.
type DockerRunner struct {
// DockerPath is the docker binary (default "docker").
DockerPath string
// SELinuxLabel is the volume relabel suffix. On Fedora this must be "z"
// (shared) or "Z" (private) so bind mounts are accessible under SELinux.
SELinuxLabel string
// WorkRoot is where per-job scratch directories are created
// (default os.TempDir()).
WorkRoot string
// Stdout receives streamed container output (nil discards the stream; the
// captured output is always returned in Result regardless).
Stdout io.Writer
}
// NewDockerRunner builds a runner with sensible defaults for this host.
func NewDockerRunner() *DockerRunner {
return &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
}
// Run implements Runner.
func (r *DockerRunner) Run(ctx context.Context, job Job) (Result, error) {
if job.Image == "" {
return Result{}, errors.New("job image is empty")
}
jobDir, err := r.prepareJobDir(job)
if err != nil {
return Result{}, err
}
defer os.RemoveAll(jobDir)
if job.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, job.Timeout)
defer cancel()
}
args := r.buildArgs(job, jobDir)
start := time.Now()
cmd := exec.CommandContext(ctx, r.DockerPath, args...)
var buf bytes.Buffer
if r.Stdout != nil {
cmd.Stdout = io.MultiWriter(&buf, r.Stdout)
cmd.Stderr = io.MultiWriter(&buf, r.Stdout)
} else {
cmd.Stdout = &buf
cmd.Stderr = &buf
}
runErr := cmd.Run()
res := Result{Output: buf.String(), Duration: time.Since(start)}
if ctx.Err() == context.DeadlineExceeded {
res.TimedOut = true
res.ExitCode = -1
return res, fmt.Errorf("job %q timed out after %s", job.Label, job.Timeout)
}
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res, nil
}
if runErr != nil {
return res, fmt.Errorf("running docker: %w", runErr)
}
res.ExitCode = 0
return res, nil
}
// prepareJobDir materialises the mounted files for a job: the prompt, the job
// script, a private copy of the Claude config (so token refreshes never touch
// the host's real config), and a copy of the personality's tea config.
func (r *DockerRunner) prepareJobDir(job Job) (string, error) {
root := r.WorkRoot
if root == "" {
root = os.TempDir()
}
if err := os.MkdirAll(root, 0o700); err != nil {
return "", err
}
jobDir, err := os.MkdirTemp(root, "teabot-job-")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(jobDir, "prompt.txt"), []byte(job.Prompt), 0o600); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(jobDir, "job.sh"), []byte(jobScript), 0o700); err != nil {
return "", err
}
// Copy the Claude config dir so the container can refresh subscription
// tokens without mutating the host's ~/.claude.
if job.ClaudeConfigDir != "" {
if _, statErr := os.Stat(job.ClaudeConfigDir); statErr == nil {
if err := copyTree(job.ClaudeConfigDir, filepath.Join(jobDir, "claude")); err != nil {
return "", fmt.Errorf("copying claude config: %w", err)
}
}
}
// Copy the personality's tea config to the mounted XDG location.
if job.TeaConfigPath != "" {
teaDir := filepath.Join(jobDir, "tea")
if err := os.MkdirAll(teaDir, 0o700); err != nil {
return "", err
}
if err := copyFile(job.TeaConfigPath, filepath.Join(teaDir, "config.yml")); err != nil {
return "", fmt.Errorf("copying tea config: %w", err)
}
}
return jobDir, nil
}
// buildArgs assembles the full `docker run` argument list for a job. It is pure
// (given jobDir) so it can be unit-tested without invoking docker.
func (r *DockerRunner) buildArgs(job Job, jobDir string) []string {
home := job.ContainerHome
if home == "" {
home = "/home/agent"
}
label := r.SELinuxLabel
mount := func(host, container string, ro bool) string {
spec := host + ":" + container
if ro {
spec += ":ro"
if label != "" {
spec += "," + label
}
} else if label != "" {
spec += ":" + label
}
return spec
}
args := []string{"run", "--rm", "--entrypoint", "/bin/bash"}
// Mount the job scratch (prompt + script) read-only.
args = append(args, "-v", mount(filepath.Join(jobDir, "prompt.txt"), "/teabot/prompt.txt", true))
args = append(args, "-v", mount(filepath.Join(jobDir, "job.sh"), "/teabot/job.sh", true))
// Mount the private Claude config copy read-write (token refresh).
if job.ClaudeConfigDir != "" {
args = append(args, "-v", mount(filepath.Join(jobDir, "claude"), home+"/.claude", false))
}
// Mount the tea config read-only at the XDG path.
if job.TeaConfigPath != "" {
args = append(args, "-v", mount(filepath.Join(jobDir, "tea", "config.yml"), home+"/.config/tea/config.yml", true))
}
// Environment consumed by job.sh.
env := map[string]string{
"TEABOT_HOME": home,
"TEABOT_GIT_NAME": job.GitName,
"TEABOT_GIT_EMAIL": job.GitEmail,
"TEABOT_GIT_USER": job.GitUser,
"TEABOT_TOKEN": job.Token,
"TEABOT_GIT_HOST": job.GitHost,
"TEABOT_CLONE_URL": job.CloneURL,
"XDG_CONFIG_HOME": home + "/.config",
}
if job.AnthropicAPIKey != "" {
env["ANTHROPIC_API_KEY"] = job.AnthropicAPIKey
}
if job.AnthropicBaseURL != "" {
env["ANTHROPIC_BASE_URL"] = job.AnthropicBaseURL
}
for _, k := range sortedKeys(env) {
args = append(args, "-e", k+"="+env[k])
}
args = append(args, job.Image, "/teabot/job.sh")
return args
}
// sortedKeys returns map keys in deterministic order (stable docker args ease
// testing and logging).
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// simple insertion sort avoids importing sort for a tiny map
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
keys[j-1], keys[j] = keys[j], keys[j-1]
}
}
return keys
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return nil
}
// copyTree recursively copies a directory tree (regular files, dirs, and
// symlink targets are dereferenced by copyFile via Open).
func copyTree(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o700)
}
if !info.Mode().IsRegular() {
return nil // skip sockets/devices; symlinks are followed by Walk's lstat -> handle below
}
return copyFile(path, target)
})
}
+161
View File
@@ -0,0 +1,161 @@
package docker
import (
"context"
"strings"
"testing"
"time"
)
// argsString joins docker args for easy substring assertions.
func argsString(a []string) string { return strings.Join(a, " ") }
// hasFlagValue reports whether args contains flag immediately followed by value.
func hasFlagValue(args []string, flag, value string) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] == flag && args[i+1] == value {
return true
}
}
return false
}
func baseJob() Job {
return Job{
Label: "unkin/teabot#issue-1",
Image: "git.unkin.net/unkin/agent-dev:latest",
ContainerHome: "/home/agent",
Prompt: "do the thing",
CloneURL: "https://git.unkin.net/unkin/teabot.git",
GitHost: "git.unkin.net",
GitName: "Impl Bot",
GitEmail: "impl@unkin.net",
GitUser: "implbot",
Token: "secret-token",
TeaConfigPath: "/home/ben/.config/teabot/tea-impl.yml",
ClaudeConfigDir: "/home/ben/.claude",
}
}
func TestBuildArgsCoreShape(t *testing.T) {
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/job123")
s := argsString(args)
if args[0] != "run" {
t.Errorf("first arg = %q, want run", args[0])
}
if !hasFlagValue(args, "--entrypoint", "/bin/bash") {
t.Error("missing --entrypoint /bin/bash")
}
if !strings.Contains(s, "--rm") {
t.Error("missing --rm")
}
// Image and job script must be the trailing args.
if args[len(args)-2] != "git.unkin.net/unkin/agent-dev:latest" || args[len(args)-1] != "/teabot/job.sh" {
t.Errorf("trailing args = %v", args[len(args)-2:])
}
}
func TestBuildArgsMountsWithSELinuxLabel(t *testing.T) {
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/job123")
s := argsString(args)
// Prompt + job script mounted read-only with the SELinux relabel.
if !strings.Contains(s, "/tmp/job123/prompt.txt:/teabot/prompt.txt:ro,z") {
t.Error("prompt mount missing or wrong flags")
}
if !strings.Contains(s, "/tmp/job123/job.sh:/teabot/job.sh:ro,z") {
t.Error("job.sh mount missing or wrong flags")
}
// Claude config copy mounted read-write with relabel.
if !strings.Contains(s, "/tmp/job123/claude:/home/agent/.claude:z") {
t.Error("claude mount missing or wrong flags")
}
// Tea config mounted read-only at the XDG path.
if !strings.Contains(s, "/tmp/job123/tea/config.yml:/home/agent/.config/tea/config.yml:ro,z") {
t.Error("tea config mount missing or wrong flags")
}
}
func TestBuildArgsInjectsGitAndXDGEnv(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
args := r.buildArgs(baseJob(), "/tmp/j")
checks := map[string]string{
"TEABOT_GIT_USER": "implbot",
"TEABOT_TOKEN": "secret-token",
"TEABOT_GIT_HOST": "git.unkin.net",
"TEABOT_CLONE_URL": "https://git.unkin.net/unkin/teabot.git",
"TEABOT_HOME": "/home/agent",
"XDG_CONFIG_HOME": "/home/agent/.config",
}
for k, v := range checks {
if !hasFlagValue(args, "-e", k+"="+v) {
t.Errorf("missing env -e %s=%s", k, v)
}
}
}
func TestBuildArgsAnthropicEnvOptIn(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
// Without keys, no ANTHROPIC_* env should be injected.
args := r.buildArgs(baseJob(), "/tmp/j")
if strings.Contains(argsString(args), "ANTHROPIC_API_KEY") {
t.Error("ANTHROPIC_API_KEY injected when unset")
}
// With keys, both are injected.
j := baseJob()
j.AnthropicAPIKey = "sk-test"
j.AnthropicBaseURL = "https://gw.example.com"
args = r.buildArgs(j, "/tmp/j")
if !hasFlagValue(args, "-e", "ANTHROPIC_API_KEY=sk-test") {
t.Error("missing ANTHROPIC_API_KEY env")
}
if !hasFlagValue(args, "-e", "ANTHROPIC_BASE_URL=https://gw.example.com") {
t.Error("missing ANTHROPIC_BASE_URL env")
}
}
func TestBuildArgsOmitsClaudeMountWhenUnset(t *testing.T) {
r := &DockerRunner{SELinuxLabel: "z"}
j := baseJob()
j.ClaudeConfigDir = ""
j.TeaConfigPath = ""
s := argsString(r.buildArgs(j, "/tmp/j"))
if strings.Contains(s, ".claude") {
t.Error("claude mount present despite empty ClaudeConfigDir")
}
if strings.Contains(s, "tea/config.yml") {
t.Error("tea mount present despite empty TeaConfigPath")
}
}
func TestJobScriptIsBash(t *testing.T) {
if !strings.HasPrefix(jobScript, "#!/usr/bin/env bash") {
t.Error("job script missing bash shebang")
}
for _, needed := range []string{"git clone", "claude --print", "credential.helper store", "/teabot/prompt.txt"} {
if !strings.Contains(jobScript, needed) {
t.Errorf("job script missing %q", needed)
}
}
}
// fakeRunner demonstrates the Runner interface is satisfiable without docker.
type fakeRunner struct{ jobs []Job }
func (f *fakeRunner) Run(_ context.Context, j Job) (Result, error) {
f.jobs = append(f.jobs, j)
return Result{ExitCode: 0, Duration: time.Millisecond}, nil
}
func TestRunnerInterfaceSatisfiedByFake(t *testing.T) {
var r Runner = &fakeRunner{}
res, err := r.Run(context.Background(), baseJob())
if err != nil || res.ExitCode != 0 {
t.Fatalf("fake runner: res=%+v err=%v", res, err)
}
}
+64
View File
@@ -0,0 +1,64 @@
// Package docker runs a one-shot Claude Code session inside a container. The
// Runner interface keeps dispatch logic testable without a real Docker daemon.
package docker
import (
"context"
"time"
)
// Job fully describes a single containerised Claude session.
type Job struct {
// Label is a short identifier used for logging and the job directory name.
Label string
// Image is the container image to run.
Image string
// ContainerHome is the home directory inside Image (mount target root).
ContainerHome string
// Prompt is the full instruction handed to `claude --print`.
Prompt string
// CloneURL is the plain HTTPS clone URL of the repo to work in
// (e.g. https://git.unkin.net/unkin/teabot.git). Auth is supplied via a
// git credential store built from Token, never embedded in this URL.
CloneURL string
// GitHost is the host used for the credential store entry (e.g. git.unkin.net).
GitHost string
// GitName / GitEmail set the container's commit identity.
GitName string
GitEmail string
// GitUser is the bot's Gitea username (credential store user).
GitUser string
// Token is the bot's Gitea token, used for git push and (indirectly) tea.
Token string
// TeaConfigPath is the host path to the personality's tea config.yml,
// mounted so tea acts as this identity inside the container.
TeaConfigPath string
// ClaudeConfigDir is the host directory holding Claude Code credentials.
ClaudeConfigDir string
// AnthropicAPIKey / AnthropicBaseURL, when set, are injected as env vars
// instead of relying on the mounted subscription credentials.
AnthropicAPIKey string
AnthropicBaseURL string
// Timeout bounds the session.
Timeout time.Duration
}
// Result captures the outcome of a job.
type Result struct {
ExitCode int
Output string
Duration time.Duration
// TimedOut is true when the job was killed for exceeding Timeout.
TimedOut bool
}
// Runner executes jobs. DockerRunner is the production implementation; tests
// substitute a fake.
type Runner interface {
Run(ctx context.Context, job Job) (Result, error)
}
+224
View File
@@ -0,0 +1,224 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client is an authenticated Gitea REST client scoped to a single token.
type Client struct {
baseURL string
token string
http *http.Client
}
// NewClient builds a client for baseURL (e.g. https://git.unkin.net) using the
// given API token.
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// SetHTTPClient overrides the underlying HTTP client (used in tests).
func (c *Client) SetHTTPClient(h *http.Client) { c.http = h }
func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error {
u := c.baseURL + "/api/v1" + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decoding %s response: %w", path, err)
}
return nil
}
// splitRepo splits "owner/name" into its parts.
func splitRepo(repo string) (owner, name string, err error) {
parts := strings.SplitN(strings.Trim(repo, "/"), "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid repo %q, want owner/name", repo)
}
return parts[0], parts[1], nil
}
// ListIssues returns open issues (excluding pull requests) updated since the
// given time. A zero time returns all open issues.
func (c *Client) ListIssues(ctx context.Context, repo string, since time.Time) ([]Issue, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("type", "issues")
q.Set("state", "open")
q.Set("limit", "50")
if !since.IsZero() {
q.Set("since", since.UTC().Format(time.RFC3339))
}
var issues []Issue
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues", owner, name), q, &issues); err != nil {
return nil, err
}
// Defensive: the API should exclude PRs given type=issues, but drop any
// that slip through.
out := issues[:0]
for _, i := range issues {
if !i.IsPull() {
out = append(out, i)
}
}
return out, nil
}
// ListPulls returns open pull requests for a repo, most-recently-updated first.
func (c *Client) ListPulls(ctx context.Context, repo string) ([]PullRequest, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("state", "open")
q.Set("sort", "recentupdate")
q.Set("limit", "50")
var pulls []PullRequest
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/pulls", owner, name), q, &pulls); err != nil {
return nil, err
}
return pulls, nil
}
// ListComments returns issue/PR comments across a repo updated since the given
// time. Gitea's repo-level comments endpoint covers both issues and PRs.
func (c *Client) ListComments(ctx context.Context, repo string, since time.Time) ([]Comment, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("limit", "50")
if !since.IsZero() {
q.Set("since", since.UTC().Format(time.RFC3339))
}
var comments []Comment
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues/comments", owner, name), q, &comments); err != nil {
return nil, err
}
return comments, nil
}
// GetIssueComments returns all comments on a single issue or PR (by index),
// used to build follow-up thread context.
func (c *Client) GetIssueComments(ctx context.Context, repo string, index int64) ([]Comment, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
var comments []Comment
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, name, index)
if err := c.get(ctx, path, nil, &comments); err != nil {
return nil, err
}
return comments, nil
}
// GetIssue fetches a single issue (or PR, which Gitea also serves here) by index.
func (c *Client) GetIssue(ctx context.Context, repo string, index int64) (Issue, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return Issue{}, err
}
var issue Issue
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, name, index)
if err := c.get(ctx, path, nil, &issue); err != nil {
return Issue{}, err
}
return issue, nil
}
// GetPull fetches a single pull request by index.
func (c *Client) GetPull(ctx context.Context, repo string, index int64) (PullRequest, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return PullRequest{}, err
}
var pr PullRequest
path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, name, index)
if err := c.get(ctx, path, nil, &pr); err != nil {
return PullRequest{}, err
}
return pr, nil
}
// GetPullDiff fetches the unified diff of a pull request for review context.
func (c *Client) GetPullDiff(ctx context.Context, repo string, index int64) (string, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return "", err
}
u := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, name, index)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("GET pull diff: status %d", resp.StatusCode)
}
return string(body), nil
}
// IssueIndexFromCommentURL extracts the issue/PR index from a comment's
// issue_url or pull_request_url (e.g. ".../issues/42" -> 42).
func IssueIndexFromCommentURL(c Comment) (int64, bool) {
raw := c.IssueURL
if raw == "" {
raw = c.PRURL
}
if raw == "" {
return 0, false
}
parts := strings.Split(strings.TrimRight(raw, "/"), "/")
if len(parts) == 0 {
return 0, false
}
n, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return 0, false
}
return n, true
}
+135
View File
@@ -0,0 +1,135 @@
package gitea
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newTestClient(t *testing.T, h http.Handler) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := NewClient(srv.URL, "test-token")
return c
}
func TestListIssuesExcludesPullsAndSendsAuth(t *testing.T) {
var gotAuth, gotType, gotState, gotSince string
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotType = r.URL.Query().Get("type")
gotState = r.URL.Query().Get("state")
gotSince = r.URL.Query().Get("since")
w.Header().Set("Content-Type", "application/json")
// One real issue and one PR-shaped issue that must be filtered out.
_, _ = w.Write([]byte(`[
{"id":1,"number":5,"title":"real issue","user":{"login":"alice"}},
{"id":2,"number":6,"title":"a pr","user":{"login":"bob"},"pull_request":{"merged":false}}
]`))
}))
since := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
issues, err := c.ListIssues(context.Background(), "unkin/teabot", since)
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if len(issues) != 1 || issues[0].Index != 5 {
t.Fatalf("expected 1 non-PR issue #5, got %+v", issues)
}
if gotAuth != "token test-token" {
t.Errorf("Authorization = %q", gotAuth)
}
if gotType != "issues" || gotState != "open" {
t.Errorf("query type=%q state=%q", gotType, gotState)
}
if gotSince != "2026-01-02T03:04:05Z" {
t.Errorf("since = %q", gotSince)
}
}
func TestListPulls(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != "open" {
t.Errorf("state = %q", r.URL.Query().Get("state"))
}
_, _ = w.Write([]byte(`[{"id":1,"number":7,"title":"add feature","user":{"login":"carol"},"head":{"ref":"benvin/x"},"base":{"ref":"main"}}]`))
}))
pulls, err := c.ListPulls(context.Background(), "unkin/teabot")
if err != nil {
t.Fatalf("ListPulls: %v", err)
}
if len(pulls) != 1 || pulls[0].Index != 7 || pulls[0].Head.Ref != "benvin/x" {
t.Fatalf("unexpected pulls: %+v", pulls)
}
}
func TestListComments(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":42,"body":"looks good","user":{"login":"dave"},"issue_url":"https://git.unkin.net/api/v1/repos/unkin/teabot/issues/5"}]`))
}))
comments, err := c.ListComments(context.Background(), "unkin/teabot", time.Time{})
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(comments) != 1 || comments[0].ID != 42 {
t.Fatalf("unexpected comments: %+v", comments)
}
}
func TestGetPullDiff(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/unkin/teabot/pulls/7.diff" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte("diff --git a/x b/x\n+hello\n"))
}))
diff, err := c.GetPullDiff(context.Background(), "unkin/teabot", 7)
if err != nil {
t.Fatalf("GetPullDiff: %v", err)
}
if diff == "" || diff[:4] != "diff" {
t.Errorf("unexpected diff: %q", diff)
}
}
func TestGetErrorsOnNon2xx(t *testing.T) {
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusForbidden)
}))
if _, err := c.ListIssues(context.Background(), "a/b", time.Time{}); err == nil {
t.Error("expected error on 403")
}
}
func TestSplitRepoValidation(t *testing.T) {
c := NewClient("https://example.com", "t")
if _, err := c.ListIssues(context.Background(), "noslash", time.Time{}); err == nil {
t.Error("expected error for repo without slash")
}
}
func TestIssueIndexFromCommentURL(t *testing.T) {
cases := []struct {
name string
comment Comment
want int64
ok bool
}{
{"issue url", Comment{IssueURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/5"}, 5, true},
{"pr url", Comment{PRURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/12"}, 12, true},
{"trailing slash", Comment{IssueURL: "https://x/issues/8/"}, 8, true},
{"no url", Comment{}, 0, false},
{"non-numeric", Comment{IssueURL: "https://x/issues/abc"}, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := IssueIndexFromCommentURL(tc.comment)
if ok != tc.ok || got != tc.want {
t.Errorf("got (%d,%v), want (%d,%v)", got, ok, tc.want, tc.ok)
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package gitea is a small read-mostly client for the Gitea REST API covering
// the endpoints teabot needs: listing issues, pull requests, and comments.
package gitea
import "time"
// User is the subset of a Gitea user teabot cares about.
type User struct {
Login string `json:"login"`
ID int64 `json:"id"`
}
// Issue represents a Gitea issue. Gitea's issues endpoint also returns pull
// requests; the PullRequest field is non-nil for those.
type Issue struct {
ID int64 `json:"id"`
Index int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Poster User `json:"user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
PullRequest *PullRequestRef `json:"pull_request,omitempty"`
}
// IsPull reports whether this issue is actually a pull request.
func (i Issue) IsPull() bool { return i.PullRequest != nil }
// PullRequestRef is the marker Gitea attaches to issues that are PRs.
type PullRequestRef struct {
Merged bool `json:"merged"`
}
// PullRequest represents a Gitea pull request.
type PullRequest struct {
ID int64 `json:"id"`
Index int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Poster User `json:"user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
Head Branch `json:"head"`
Base Branch `json:"base"`
}
// Branch is one side of a pull request.
type Branch struct {
Ref string `json:"ref"`
Sha string `json:"sha"`
}
// Comment is a comment on an issue or pull request.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
Poster User `json:"user"`
Created time.Time `json:"created_at"`
Updated time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
IssueURL string `json:"issue_url"`
PRURL string `json:"pull_request_url"`
}
+163
View File
@@ -0,0 +1,163 @@
// Package prompt builds the task-specific instructions handed to a one-shot
// Claude Code session for each kind of Gitea event.
package prompt
import (
"fmt"
"strings"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// conventions is appended to every prompt so sessions follow the house rules
// (branch naming, PR body shape, and the loop-safety expectation of finishing
// in one pass).
const conventions = `Conventions you MUST follow:
- Work on a branch named benvin/<short-descriptive-name>; never push to the default branch.
- Use HTTPS remotes for git.unkin.net (SSH is blocked). The clone is already authenticated.
- PR descriptions have a short "why" paragraph followed by present-tense "how" bullets.
- Use the tea CLI (already configured for your bot identity) for Gitea actions such as creating PRs and posting comments; never use gh.
- This is a single non-interactive session: complete the task in one pass, then stop. Do not wait for input.`
// IssueContext carries everything needed to prompt an implementer session for a
// newly opened issue.
type IssueContext struct {
Repo string
PersonalityName string
Issue gitea.Issue
Comments []gitea.Comment
}
// Issue builds the prompt for reviewing and possibly implementing an issue.
func Issue(c IssueContext) string {
var b strings.Builder
fmt.Fprintf(&b, "You are %q, an autonomous implementer bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new issue was opened. Review it and decide whether it warrants a code change.\n")
fmt.Fprintf(&b, "If it does, implement the change on a new branch and open a pull request that links the issue with \"Closes #%d\".\n", c.Issue.Index)
fmt.Fprintf(&b, "If it does NOT warrant a code change (question, discussion, invalid, needs clarification), post a brief comment on the issue explaining your assessment instead of opening a PR.\n\n")
fmt.Fprintf(&b, "Issue #%d: %s\n", c.Issue.Index, c.Issue.Title)
fmt.Fprintf(&b, "Opened by: %s\n", c.Issue.Poster.Login)
fmt.Fprintf(&b, "URL: %s\n\n", c.Issue.HTMLURL)
b.WriteString("Issue body:\n")
b.WriteString(bodyOrNone(c.Issue.Body))
b.WriteString("\n")
writeComments(&b, c.Comments)
b.WriteString("\n")
b.WriteString(conventions)
return b.String()
}
// PullContext carries everything needed to prompt a reviewer session for a PR.
type PullContext struct {
Repo string
PersonalityName string
Pull gitea.PullRequest
Diff string
Comments []gitea.Comment
}
// Pull builds the prompt for reviewing a pull request.
func Pull(c PullContext) string {
var b strings.Builder
fmt.Fprintf(&b, "You are %q, an autonomous reviewer bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new pull request was opened. Review it thoroughly and decide whether the change is correct and ready.\n")
b.WriteString("Judge: does the diff actually satisfy the PR description? Is it correct, tested, and consistent with the codebase's style?\n")
fmt.Fprintf(&b, "Post your review on PR #%d using tea: an approving review if it is good, or a review requesting changes with specific, actionable comments if not.\n\n", c.Pull.Index)
fmt.Fprintf(&b, "PR #%d: %s\n", c.Pull.Index, c.Pull.Title)
fmt.Fprintf(&b, "Opened by: %s\n", c.Pull.Poster.Login)
fmt.Fprintf(&b, "Branch: %s -> %s\n", c.Pull.Head.Ref, c.Pull.Base.Ref)
fmt.Fprintf(&b, "URL: %s\n\n", c.Pull.HTMLURL)
b.WriteString("PR description:\n")
b.WriteString(bodyOrNone(c.Pull.Body))
b.WriteString("\n")
writeComments(&b, c.Comments)
if strings.TrimSpace(c.Diff) != "" {
b.WriteString("\nUnified diff:\n")
b.WriteString(truncate(c.Diff, 60000))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(conventions)
return b.String()
}
// FollowUpKind distinguishes a follow-up on an issue vs a pull request.
type FollowUpKind string
const (
// FollowUpIssue is a new comment on an issue teabot acted on.
FollowUpIssue FollowUpKind = "issue"
// FollowUpPull is a new comment on a pull request teabot acted on.
FollowUpPull FollowUpKind = "pull"
)
// FollowUpContext carries everything needed to prompt a follow-up session for a
// new comment on a thread teabot previously engaged with.
type FollowUpContext struct {
Repo string
PersonalityName string
Kind FollowUpKind
Index int64
Title string
URL string
Comments []gitea.Comment // full thread, oldest first
NewComment gitea.Comment // the comment that triggered this follow-up
}
// FollowUp builds the prompt for responding to a new comment.
func FollowUp(c FollowUpContext) string {
var b strings.Builder
noun := "issue"
if c.Kind == FollowUpPull {
noun = "pull request"
}
fmt.Fprintf(&b, "You are %q, an autonomous bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new comment was posted on %s #%d, a thread you previously worked on. Read the full thread and respond or take action as appropriate.\n", noun, c.Index)
if c.Kind == FollowUpPull {
b.WriteString("If the comment requests changes, check out the PR branch, make the changes, and push them; then reply summarising what you did.\n")
} else {
b.WriteString("If the comment asks for a change or clarifies the request, implement it on a branch and open or update the PR; otherwise reply with a helpful comment.\n")
}
fmt.Fprintf(&b, "\n%s #%d: %s\n", strings.ToUpper(noun[:1])+noun[1:], c.Index, c.Title)
fmt.Fprintf(&b, "URL: %s\n\n", c.URL)
b.WriteString("Full thread (oldest first):\n")
writeComments(&b, c.Comments)
fmt.Fprintf(&b, "\nThe new comment that triggered this task was posted by %s:\n", c.NewComment.Poster.Login)
b.WriteString(bodyOrNone(c.NewComment.Body))
b.WriteString("\n\n")
b.WriteString(conventions)
return b.String()
}
func writeComments(b *strings.Builder, comments []gitea.Comment) {
if len(comments) == 0 {
return
}
b.WriteString("\nComments (oldest first):\n")
for _, c := range comments {
fmt.Fprintf(b, "- %s: %s\n", c.Poster.Login, oneLine(c.Body))
}
}
func bodyOrNone(s string) string {
if strings.TrimSpace(s) == "" {
return "(no description provided)"
}
return s
}
func oneLine(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n... [diff truncated] ..."
}
+98
View File
@@ -0,0 +1,98 @@
package prompt
import (
"strings"
"testing"
"git.unkin.net/unkin/teabot/internal/gitea"
)
func TestIssuePromptContainsContextAndConventions(t *testing.T) {
p := Issue(IssueContext{
Repo: "unkin/teabot",
PersonalityName: "implementer",
Issue: gitea.Issue{
Index: 42,
Title: "Add a flag",
Body: "Please add --verbose",
Poster: gitea.User{Login: "alice"},
HTMLURL: "https://git.unkin.net/unkin/teabot/issues/42",
},
Comments: []gitea.Comment{{Poster: gitea.User{Login: "bob"}, Body: "agreed"}},
})
mustContain(t, p, "implementer")
mustContain(t, p, "unkin/teabot")
mustContain(t, p, "Add a flag")
mustContain(t, p, "Please add --verbose")
mustContain(t, p, "Closes #42") // must instruct linking the issue
mustContain(t, p, "bob: agreed")
mustContain(t, p, "benvin/") // branch-naming convention
mustContain(t, p, "present-tense")
}
func TestIssuePromptHandlesEmptyBody(t *testing.T) {
p := Issue(IssueContext{
Repo: "a/b",
Issue: gitea.Issue{Index: 1, Title: "t"},
})
mustContain(t, p, "(no description provided)")
}
func TestPullPromptIncludesDiffAndReviewInstruction(t *testing.T) {
p := Pull(PullContext{
Repo: "unkin/teabot",
PersonalityName: "reviewer",
Pull: gitea.PullRequest{
Index: 7,
Title: "Implement thing",
Body: "does the thing",
Poster: gitea.User{Login: "carol"},
Head: gitea.Branch{Ref: "benvin/thing"},
Base: gitea.Branch{Ref: "main"},
},
Diff: "diff --git a/x b/x\n+added line\n",
})
mustContain(t, p, "reviewer")
mustContain(t, p, "PR #7")
mustContain(t, p, "does the diff actually satisfy")
mustContain(t, p, "benvin/thing -> main")
mustContain(t, p, "diff --git a/x b/x")
mustContain(t, p, "+added line")
}
func TestPullPromptTruncatesHugeDiff(t *testing.T) {
huge := strings.Repeat("x", 70000)
p := Pull(PullContext{Repo: "a/b", Pull: gitea.PullRequest{Index: 1}, Diff: huge})
mustContain(t, p, "[diff truncated]")
if len(p) > 70000 {
t.Errorf("prompt not truncated, len=%d", len(p))
}
}
func TestFollowUpIssueVsPullWording(t *testing.T) {
issueThread := []gitea.Comment{{Poster: gitea.User{Login: "a"}, Body: "first"}}
trigger := gitea.Comment{Poster: gitea.User{Login: "human"}, Body: "please tweak it"}
ip := FollowUp(FollowUpContext{
Repo: "a/b", Kind: FollowUpIssue, Index: 3, Title: "T",
URL: "u", Comments: issueThread, NewComment: trigger,
})
mustContain(t, ip, "issue #3")
mustContain(t, ip, "please tweak it")
mustContain(t, ip, "posted by human")
pp := FollowUp(FollowUpContext{
Repo: "a/b", Kind: FollowUpPull, Index: 4, Title: "T2",
URL: "u", Comments: issueThread, NewComment: trigger,
})
mustContain(t, pp, "pull request #4")
mustContain(t, pp, "check out the PR branch")
}
func mustContain(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("prompt missing %q\n---\n%s", needle, haystack)
}
}
+219
View File
@@ -0,0 +1,219 @@
// Package state persists which Gitea events teabot has already handled so a
// restart does not re-trigger work. State is a single JSON file under the
// configured state directory (default ~/.local/state/teabot/state.json).
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// StateFileName is the JSON file holding processed-event bookkeeping.
const StateFileName = "state.json"
// RepoState tracks what has been handled for one repository.
type RepoState struct {
// ProcessedIssues holds issue indexes already dispatched.
ProcessedIssues map[int64]bool `json:"processed_issues"`
// ProcessedPulls holds pull-request indexes already dispatched.
ProcessedPulls map[int64]bool `json:"processed_pulls"`
// ProcessedComments holds comment IDs already dispatched.
ProcessedComments map[int64]bool `json:"processed_comments"`
// ActedIssues/ActedPulls record which issues/PRs teabot ran a session
// for, so comment follow-ups only fire on threads the bot engaged with.
ActedIssues map[int64]bool `json:"acted_issues"`
ActedPulls map[int64]bool `json:"acted_pulls"`
// Seeded is set the first time a repo is polled: existing open issues/PRs
// and recent comments are recorded as processed WITHOUT dispatching, so a
// fresh install does not stampede every open item.
Seeded bool `json:"seeded"`
// LastPoll is the time of the last completed poll, used to bound `since`
// queries on subsequent cycles.
LastPoll time.Time `json:"last_poll"`
}
func newRepoState() *RepoState {
return &RepoState{
ProcessedIssues: map[int64]bool{},
ProcessedPulls: map[int64]bool{},
ProcessedComments: map[int64]bool{},
ActedIssues: map[int64]bool{},
ActedPulls: map[int64]bool{},
}
}
// data is the on-disk document.
type data struct {
Repos map[string]*RepoState `json:"repos"`
}
// Store is a thread-safe, file-backed processed-event tracker.
type Store struct {
path string
mu sync.Mutex
d *data
}
// New loads the store from dir, creating an empty one if the file is absent.
func New(dir string) (*Store, error) {
s := &Store{
path: filepath.Join(dir, StateFileName),
d: &data{Repos: map[string]*RepoState{}},
}
raw, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, fmt.Errorf("reading state %s: %w", s.path, err)
}
if len(raw) == 0 {
return s, nil
}
if err := json.Unmarshal(raw, s.d); err != nil {
return nil, fmt.Errorf("parsing state %s: %w", s.path, err)
}
if s.d.Repos == nil {
s.d.Repos = map[string]*RepoState{}
}
return s, nil
}
// repo returns the RepoState for repo, creating it if needed. Caller holds mu.
func (s *Store) repo(repo string) *RepoState {
rs := s.d.Repos[repo]
if rs == nil {
rs = newRepoState()
s.d.Repos[repo] = rs
}
// Guard against a partially-populated document loaded from disk.
if rs.ProcessedIssues == nil {
rs.ProcessedIssues = map[int64]bool{}
}
if rs.ProcessedPulls == nil {
rs.ProcessedPulls = map[int64]bool{}
}
if rs.ProcessedComments == nil {
rs.ProcessedComments = map[int64]bool{}
}
if rs.ActedIssues == nil {
rs.ActedIssues = map[int64]bool{}
}
if rs.ActedPulls == nil {
rs.ActedPulls = map[int64]bool{}
}
return rs
}
// IssueProcessed reports whether an issue index was already handled.
func (s *Store) IssueProcessed(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ProcessedIssues[index]
}
// PullProcessed reports whether a PR index was already handled.
func (s *Store) PullProcessed(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ProcessedPulls[index]
}
// CommentProcessed reports whether a comment ID was already handled.
func (s *Store) CommentProcessed(repo string, id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ProcessedComments[id]
}
// ActedOnIssue reports whether teabot ran a session for an issue.
func (s *Store) ActedOnIssue(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ActedIssues[index]
}
// ActedOnPull reports whether teabot ran a session for a PR.
func (s *Store) ActedOnPull(repo string, index int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).ActedPulls[index]
}
// MarkIssue records an issue index as processed and acted-on.
func (s *Store) MarkIssue(repo string, index int64) {
s.mu.Lock()
rs := s.repo(repo)
rs.ProcessedIssues[index] = true
rs.ActedIssues[index] = true
s.mu.Unlock()
}
// MarkPull records a PR index as processed and acted-on.
func (s *Store) MarkPull(repo string, index int64) {
s.mu.Lock()
rs := s.repo(repo)
rs.ProcessedPulls[index] = true
rs.ActedPulls[index] = true
s.mu.Unlock()
}
// MarkComment records a comment ID as processed.
func (s *Store) MarkComment(repo string, id int64) {
s.mu.Lock()
s.repo(repo).ProcessedComments[id] = true
s.mu.Unlock()
}
// Seeded reports whether a repo has completed its baseline seeding poll.
func (s *Store) Seeded(repo string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).Seeded
}
// MarkSeeded records that a repo has completed baseline seeding.
func (s *Store) MarkSeeded(repo string) {
s.mu.Lock()
s.repo(repo).Seeded = true
s.mu.Unlock()
}
// LastPoll returns the time of the last completed poll for a repo.
func (s *Store) LastPoll(repo string) time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo(repo).LastPoll
}
// SetLastPoll records the time of the last completed poll for a repo.
func (s *Store) SetLastPoll(repo string, t time.Time) {
s.mu.Lock()
s.repo(repo).LastPoll = t
s.mu.Unlock()
}
// Save atomically writes the state document to disk.
func (s *Store) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("creating state dir: %w", err)
}
raw, err := json.MarshalIndent(s.d, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return fmt.Errorf("writing state: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("committing state: %w", err)
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package state
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestMarkAndQuery(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
const repo = "unkin/teabot"
if s.IssueProcessed(repo, 1) {
t.Error("fresh store should not report issue 1 processed")
}
s.MarkIssue(repo, 1)
if !s.IssueProcessed(repo, 1) {
t.Error("issue 1 should be processed after MarkIssue")
}
if !s.ActedOnIssue(repo, 1) {
t.Error("MarkIssue should also record acted-on")
}
if s.ActedOnPull(repo, 1) {
t.Error("issue mark must not set acted-on-pull")
}
s.MarkPull(repo, 2)
if !s.PullProcessed(repo, 2) || !s.ActedOnPull(repo, 2) {
t.Error("pull 2 should be processed and acted-on")
}
s.MarkComment(repo, 99)
if !s.CommentProcessed(repo, 99) {
t.Error("comment 99 should be processed")
}
if s.CommentProcessed(repo, 100) {
t.Error("comment 100 was never marked")
}
}
func TestPersistenceRoundTrip(t *testing.T) {
dir := t.TempDir()
s1, err := New(dir)
if err != nil {
t.Fatal(err)
}
const repo = "a/b"
s1.MarkIssue(repo, 10)
s1.MarkPull(repo, 11)
s1.MarkComment(repo, 12)
s1.MarkSeeded(repo)
now := time.Now().Truncate(time.Second)
s1.SetLastPoll(repo, now)
if err := s1.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
// A fresh Store loaded from the same dir must see the persisted state.
s2, err := New(dir)
if err != nil {
t.Fatal(err)
}
if !s2.IssueProcessed(repo, 10) || !s2.PullProcessed(repo, 11) || !s2.CommentProcessed(repo, 12) {
t.Error("processed sets did not survive reload")
}
if !s2.ActedOnIssue(repo, 10) || !s2.ActedOnPull(repo, 11) {
t.Error("acted-on sets did not survive reload")
}
if !s2.Seeded(repo) {
t.Error("seeded flag did not survive reload")
}
if !s2.LastPoll(repo).Equal(now) {
t.Errorf("LastPoll = %v, want %v", s2.LastPoll(repo), now)
}
}
func TestSaveIsAtomicFile(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
s.MarkIssue("a/b", 1)
if err := s.Save(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, StateFileName)); err != nil {
t.Errorf("state file missing after Save: %v", err)
}
// No leftover temp file.
if _, err := os.Stat(filepath.Join(dir, StateFileName+".tmp")); !os.IsNotExist(err) {
t.Error("temp file should not remain after atomic rename")
}
}
func TestSeededIndependentPerRepo(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
s.MarkSeeded("a/b")
if s.Seeded("c/d") {
t.Error("seeding a/b must not seed c/d")
}
if !s.Seeded("a/b") {
t.Error("a/b should be seeded")
}
}
func TestLoadCorruptStateFails(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, StateFileName), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New(dir); err == nil {
t.Error("expected error loading corrupt state file")
}
}