Files
klaus-bot/main.go
T
2026-05-28 19:49:29 +00:00

142 lines
3.5 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
type Config struct {
ListenAddr string
WorkspaceRoot string // mkdtemp parent. empty = os.TempDir()
JobTimeout time.Duration // max wall-clock for a single Claude session
}
func loadConfig() Config {
cfg := Config{
ListenAddr: envOr("KLAUS_LISTEN", ":8020"),
WorkspaceRoot: os.Getenv("KLAUS_WORKSPACE"),
JobTimeout: 20 * time.Minute,
}
if v := os.Getenv("KLAUS_JOB_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.JobTimeout = d
} else {
log.Printf("ignoring invalid KLAUS_JOB_TIMEOUT=%q: %v", v, err)
}
}
if cfg.WorkspaceRoot != "" {
if err := os.MkdirAll(cfg.WorkspaceRoot, 0o755); err != nil {
log.Fatalf("create workspace root %s: %v", cfg.WorkspaceRoot, err)
}
}
return cfg
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func main() {
cfg := loadConfig()
worker := NewWorker(cfg)
http.HandleFunc("/gitea-webhook", func(w http.ResponseWriter, r *http.Request) {
eventType := r.Header.Get("X-Gitea-Event")
if eventType == "" {
eventType = r.Header.Get("X-Gogs-Event")
}
log.Printf("%s %s from %s event=%s", r.Method, r.URL.Path, r.RemoteAddr, eventType)
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
if !json.Valid(body) {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
event, trigger, err := DecideTrigger(eventType, body)
if err != nil {
log.Printf("decode webhook: %v", err)
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if trigger == nil {
log.Printf("[skip] %s(action=%s) on %s by @%s — no klaus trigger; %s",
eventType, event.Action, event.Repository.FullName, event.Sender.Name(),
skipHint(eventType, event))
w.WriteHeader(http.StatusOK)
return
}
log.Printf("[queue] %s on %s#%d — reason=%s (by @%s)",
eventType, event.Repository.FullName, trigger.Number, trigger.Reason, event.Sender.Name())
worker.Enqueue(Job{
EventType: eventType,
Event: event,
Trigger: trigger,
})
w.WriteHeader(http.StatusAccepted)
})
log.Printf("klaus-bot listening on %s (workspace=%s, timeout=%s)",
cfg.ListenAddr, displayPath(cfg.WorkspaceRoot), cfg.JobTimeout)
log.Fatal(http.ListenAndServe(cfg.ListenAddr, nil))
}
func displayPath(p string) string {
if p == "" {
return os.TempDir() + " (default)"
}
return p
}
// skipHint produces a short diagnostic suffix for the "[skip]" log line so
// operators can tell at a glance why a payload didn't trigger Klaus.
func skipHint(eventType string, ev *GiteaEvent) string {
snippet := func(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 120 {
s = s[:120] + "…"
}
return s
}
switch eventType {
case "issue_comment":
if ev.Comment != nil {
return fmt.Sprintf("comment=%q", snippet(ev.Comment.Body))
}
case "issues":
if ev.Issue != nil {
return fmt.Sprintf("issue=%q", snippet(ev.Issue.Body))
}
case "pull_request":
if ev.PullRequest != nil {
return fmt.Sprintf("pr=%q", snippet(ev.PullRequest.Body))
}
case "pull_request_review":
if ev.Review != nil {
return fmt.Sprintf("review=%q", snippet(ev.Review.Body))
}
}
return "(no body)"
}