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

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

130 lines
3.8 KiB
Go

// 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)
MarkIssueProcessed(repo string, index int64)
MarkPullProcessed(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"
}