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