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)" }