1b4448afb4
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
123 lines
3.9 KiB
Go
123 lines
3.9 KiB
Go
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:]
|
|
}
|