Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in Docker containers to work issues and review PRs, acting as configurable bot personalities. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -0,0 +1,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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user