commit 0bfe0c314df151fb787e44645a7e01a884c0d4e9 Author: Klaus Date: Thu May 28 19:49:29 2026 +0000 initial diff --git a/events.go b/events.go new file mode 100644 index 0000000..3d86ddd --- /dev/null +++ b/events.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/json" + "regexp" + "strings" +) + +// Subset of the Gitea webhook payload we actually consume. Gitea reuses the +// same top-level structure across event types; fields are populated per event. +type GiteaEvent struct { + Action string `json:"action"` + Sender User `json:"sender"` + Repository Repository `json:"repository"` + Issue *Issue `json:"issue,omitempty"` + Comment *Comment `json:"comment,omitempty"` + PullRequest *PullRequest `json:"pull_request,omitempty"` + Review *Review `json:"review,omitempty"` + RequestedReviewer *User `json:"requested_reviewer,omitempty"` + Assignee *User `json:"assignee,omitempty"` + Changes *Changes `json:"changes,omitempty"` +} + +// Changes is populated for action=="edited" events. We use it to detect when +// an edit *introduced* an @Klaus mention versus an edit to a body that already +// had one (which should not re-trigger us). +type Changes struct { + Body *ChangedString `json:"body,omitempty"` + Title *ChangedString `json:"title,omitempty"` +} + +type ChangedString struct { + From string `json:"from"` +} + +type User struct { + Login string `json:"login"` + Username string `json:"username"` + ID int64 `json:"id"` +} + +func (u User) Name() string { + if u.Login != "" { + return u.Login + } + return u.Username +} + +type Repository struct { + FullName string `json:"full_name"` + Name string `json:"name"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` + Owner User `json:"owner"` +} + +type Issue struct { + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + User User `json:"user"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + PullRequest *PullRefStub `json:"pull_request,omitempty"` + Assignees []User `json:"assignees,omitempty"` +} + +// Gitea sets issue.pull_request to a small stub when an issue_comment fires on a PR. +type PullRefStub struct { + HTMLURL string `json:"html_url"` +} + +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` + User User `json:"user"` + HTMLURL string `json:"html_url"` +} + +type PullRequest struct { + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + User User `json:"user"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + Head PRBranch `json:"head"` + Base PRBranch `json:"base"` + Assignees []User `json:"assignees,omitempty"` + RequestedReviewers []User `json:"requested_reviewers,omitempty"` +} + +type PRBranch struct { + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +type Review struct { + Body string `json:"body"` + User User `json:"user"` + State string `json:"state"` +} + +// Trigger describes why Klaus is being woken up. nil means: do nothing. +type Trigger struct { + Reason string // mention | assigned | review_requested | reply_on_klaus_pr + Source string // issue | issue_comment | pull_request | pull_request_comment | pull_request_review + Body string // text that triggered us + Number int64 // issue or PR number + IsPR bool + BranchHint string // for PR-related triggers, the PR head ref to check out + HTMLURL string +} + +var mentionRE = regexp.MustCompile(`(?i)(^|[^a-z0-9_])@klaus\b`) + +func mentionsKlaus(s string) bool { return mentionRE.MatchString(s) } + +func isKlaus(u User) bool { + return strings.EqualFold(u.Login, "klaus") || strings.EqualFold(u.Username, "klaus") +} + +// shouldFireOnBody returns true when body changes warrant waking Klaus. +// For action=="created" / "opened": fire when the body mentions Klaus. +// For action=="edited": fire only when the edit *introduced* the mention +// (mention in new body but not in changes.body.from) — prevents repeat +// triggers when someone edits an unrelated part of a comment/issue/PR that +// already mentioned Klaus. +func shouldFireOnBody(action, body string, changes *Changes) bool { + if !mentionsKlaus(body) { + return false + } + if action == "edited" { + if changes == nil || changes.Body == nil { + // Body wasn't part of the edit (only title/labels/etc changed). + return false + } + if mentionsKlaus(changes.Body.From) { + return false + } + } + return true +} + +// DecideTrigger parses the raw payload and returns a Trigger only when Klaus +// should respond. Returns (event, nil, nil) when the event is benign. +func DecideTrigger(eventType string, raw []byte) (*GiteaEvent, *Trigger, error) { + var ev GiteaEvent + if err := json.Unmarshal(raw, &ev); err != nil { + return nil, nil, err + } + + // Never react to our own events — prevents feedback loops. + if isKlaus(ev.Sender) { + return &ev, nil, nil + } + + switch eventType { + case "issues": + if ev.Issue == nil { + return &ev, nil, nil + } + if ev.Action == "assigned" && ev.Assignee != nil && isKlaus(*ev.Assignee) { + return &ev, &Trigger{ + Reason: "assigned", Source: "issue", + Body: ev.Issue.Body, Number: ev.Issue.Number, HTMLURL: ev.Issue.HTMLURL, + }, nil + } + if (ev.Action == "opened" || ev.Action == "edited") && + shouldFireOnBody(ev.Action, ev.Issue.Body, ev.Changes) { + return &ev, &Trigger{ + Reason: "mention", Source: "issue", + Body: ev.Issue.Body, Number: ev.Issue.Number, HTMLURL: ev.Issue.HTMLURL, + }, nil + } + + case "issue_comment": + if ev.Comment == nil || ev.Issue == nil { + return &ev, nil, nil + } + if ev.Action != "created" && ev.Action != "edited" { + return &ev, nil, nil + } + isPR := ev.Issue.PullRequest != nil + src := "issue_comment" + if isPR { + src = "pull_request_comment" + } + if shouldFireOnBody(ev.Action, ev.Comment.Body, ev.Changes) { + return &ev, &Trigger{ + Reason: "mention", Source: src, + Body: ev.Comment.Body, Number: ev.Issue.Number, IsPR: isPR, HTMLURL: ev.Comment.HTMLURL, + }, nil + } + if ev.Action == "created" && isPR && isKlaus(ev.Issue.User) { + return &ev, &Trigger{ + Reason: "reply_on_klaus_pr", Source: src, + Body: ev.Comment.Body, Number: ev.Issue.Number, IsPR: true, HTMLURL: ev.Comment.HTMLURL, + }, nil + } + + case "pull_request": + if ev.PullRequest == nil { + return &ev, nil, nil + } + if ev.Action == "assigned" && ev.Assignee != nil && isKlaus(*ev.Assignee) { + return &ev, &Trigger{ + Reason: "assigned", Source: "pull_request", + Body: ev.PullRequest.Body, Number: ev.PullRequest.Number, + IsPR: true, BranchHint: ev.PullRequest.Head.Ref, HTMLURL: ev.PullRequest.HTMLURL, + }, nil + } + if ev.Action == "review_requested" && ev.RequestedReviewer != nil && isKlaus(*ev.RequestedReviewer) { + return &ev, &Trigger{ + Reason: "review_requested", Source: "pull_request", + Body: ev.PullRequest.Body, Number: ev.PullRequest.Number, + IsPR: true, BranchHint: ev.PullRequest.Head.Ref, HTMLURL: ev.PullRequest.HTMLURL, + }, nil + } + if (ev.Action == "opened" || ev.Action == "edited") && + shouldFireOnBody(ev.Action, ev.PullRequest.Body, ev.Changes) { + return &ev, &Trigger{ + Reason: "mention", Source: "pull_request", + Body: ev.PullRequest.Body, Number: ev.PullRequest.Number, + IsPR: true, BranchHint: ev.PullRequest.Head.Ref, HTMLURL: ev.PullRequest.HTMLURL, + }, nil + } + + case "pull_request_review": + if ev.Review == nil || ev.PullRequest == nil || ev.Action != "submitted" { + return &ev, nil, nil + } + if mentionsKlaus(ev.Review.Body) { + return &ev, &Trigger{ + Reason: "mention", Source: "pull_request_review", + Body: ev.Review.Body, Number: ev.PullRequest.Number, + IsPR: true, BranchHint: ev.PullRequest.Head.Ref, HTMLURL: ev.PullRequest.HTMLURL, + }, nil + } + } + return &ev, nil, nil +} diff --git a/events_test.go b/events_test.go new file mode 100644 index 0000000..8452b55 --- /dev/null +++ b/events_test.go @@ -0,0 +1,153 @@ +package main + +import "testing" + +func TestDecideTrigger_MentionInIssueComment(t *testing.T) { + body := []byte(`{ + "action": "created", + "sender": {"login": "lars"}, + "repository": {"full_name": "lars/demo", "ssh_url": "git@host:lars/demo.git", "default_branch": "main"}, + "issue": {"number": 7, "user": {"login": "lars"}, "html_url": "http://h/lars/demo/issues/7"}, + "comment": {"id": 1, "body": "@klaus please look at this", "user": {"login": "lars"}} + }`) + _, tr, err := DecideTrigger("issue_comment", body) + if err != nil { + t.Fatal(err) + } + if tr == nil { + t.Fatal("expected trigger, got nil") + } + if tr.Reason != "mention" || tr.Number != 7 || tr.IsPR { + t.Fatalf("unexpected trigger: %+v", tr) + } +} + +func TestDecideTrigger_AssignedPR(t *testing.T) { + body := []byte(`{ + "action": "assigned", + "sender": {"login": "lars"}, + "assignee": {"login": "Klaus"}, + "repository": {"full_name": "lars/demo", "ssh_url": "git@host:lars/demo.git", "default_branch": "main"}, + "pull_request": {"number": 12, "head": {"ref": "feat-x"}, "user": {"login": "lars"}} + }`) + _, tr, err := DecideTrigger("pull_request", body) + if err != nil { + t.Fatal(err) + } + if tr == nil || tr.Reason != "assigned" || !tr.IsPR || tr.Number != 12 || tr.BranchHint != "feat-x" { + t.Fatalf("unexpected trigger: %+v", tr) + } +} + +func TestDecideTrigger_ReplyOnKlausPR(t *testing.T) { + body := []byte(`{ + "action": "created", + "sender": {"login": "lars"}, + "repository": {"full_name": "lars/demo", "ssh_url": "git@host:lars/demo.git", "default_branch": "main"}, + "issue": {"number": 5, "user": {"login": "klaus"}, "pull_request": {"html_url": "x"}}, + "comment": {"id": 9, "body": "looks good", "user": {"login": "lars"}} + }`) + _, tr, err := DecideTrigger("issue_comment", body) + if err != nil { + t.Fatal(err) + } + if tr == nil || tr.Reason != "reply_on_klaus_pr" || !tr.IsPR { + t.Fatalf("unexpected trigger: %+v", tr) + } +} + +func TestDecideTrigger_IgnoresOwnEvents(t *testing.T) { + body := []byte(`{ + "action": "created", + "sender": {"login": "klaus"}, + "repository": {"full_name": "lars/demo"}, + "issue": {"number": 1, "user": {"login": "lars"}}, + "comment": {"body": "@klaus hi", "user": {"login": "klaus"}} + }`) + _, tr, err := DecideTrigger("issue_comment", body) + if err != nil { + t.Fatal(err) + } + if tr != nil { + t.Fatalf("expected nil trigger for self-event, got %+v", tr) + } +} + +func TestDecideTrigger_NoMentionNoTrigger(t *testing.T) { + body := []byte(`{ + "action": "created", + "sender": {"login": "lars"}, + "repository": {"full_name": "lars/demo"}, + "issue": {"number": 2, "user": {"login": "lars"}}, + "comment": {"body": "thanks everyone", "user": {"login": "lars"}} + }`) + _, tr, _ := DecideTrigger("issue_comment", body) + if tr != nil { + t.Fatalf("expected nil trigger, got %+v", tr) + } +} + +func TestDecideTrigger_EditedCommentAddsMention(t *testing.T) { + body := []byte(`{ + "action": "edited", + "sender": {"login": "lars"}, + "repository": {"full_name": "lars/demo"}, + "issue": {"number": 21, "user": {"login": "Klaus"}}, + "comment": {"id": 31, "body": "@Klaus Please respond", "user": {"login": "lars"}}, + "changes": {"body": {"from": "Hello? Can you hear me?"}} + }`) + _, tr, err := DecideTrigger("issue_comment", body) + if err != nil { + t.Fatal(err) + } + if tr == nil || tr.Reason != "mention" || tr.Number != 21 { + t.Fatalf("expected mention trigger, got %+v", tr) + } +} + +func TestDecideTrigger_EditedCommentAlreadyMentioned(t *testing.T) { + // Mention was in the original body too — do not re-trigger. + body := []byte(`{ + "action": "edited", + "sender": {"login": "lars"}, + "repository": {"full_name": "lars/demo"}, + "issue": {"number": 21, "user": {"login": "lars"}}, + "comment": {"id": 31, "body": "@Klaus please look (typo fixed)", "user": {"login": "lars"}}, + "changes": {"body": {"from": "@Klaus please look (typo)"}} + }`) + _, tr, _ := DecideTrigger("issue_comment", body) + if tr != nil { + t.Fatalf("expected nil trigger (mention pre-existed), got %+v", tr) + } +} + +func TestDecideTrigger_EditedNonBodyChange(t *testing.T) { + // changes present but no body field — e.g. labels changed. Should not trigger. + body := []byte(`{ + "action": "edited", + "sender": {"login": "lars"}, + "repository": {"full_name": "lars/demo"}, + "issue": {"number": 1, "body": "@Klaus help", "user": {"login": "lars"}}, + "changes": {"added_labels": null} + }`) + _, tr, _ := DecideTrigger("issues", body) + if tr != nil { + t.Fatalf("expected nil trigger when body wasn't part of edit, got %+v", tr) + } +} + +func TestMentionRegex_NotPartOfWord(t *testing.T) { + cases := map[string]bool{ + "hello @klaus please": true, + "@klaus do x": true, + "thanks @KLAUS": true, + "email me at not@klausmail": false, + "see klausbot": false, + "@klausen is someone else": false, + } + for in, want := range cases { + if got := mentionsKlaus(in); got != want { + t.Errorf("mentionsKlaus(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..919845a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module klaus-bot + +go 1.26.3 diff --git a/klaus-bot b/klaus-bot new file mode 100755 index 0000000..7bbd94c Binary files /dev/null and b/klaus-bot differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..2d569ac --- /dev/null +++ b/main.go @@ -0,0 +1,141 @@ +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)" +} diff --git a/prompt.go b/prompt.go new file mode 100644 index 0000000..a0ed63d --- /dev/null +++ b/prompt.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "strings" +) + +// buildPrompt renders the instructions handed to the `claude -p` session. +// It establishes Klaus's identity, summarizes the event, and states the +// rules of engagement (comment vs. open a PR). +func buildPrompt(job Job, workDir string) string { + ev := job.Event + tr := job.Trigger + + var what string + switch { + case tr.IsPR: + what = fmt.Sprintf("pull request #%d", tr.Number) + case tr.Number != 0: + what = fmt.Sprintf("issue #%d", tr.Number) + default: + what = "this event" + } + + branchNote := "" + if tr.IsPR && tr.BranchHint != "" { + branchNote = fmt.Sprintf( + "\n- The PR head branch `%s` has been checked out for you (instead of the default branch).", + tr.BranchHint, + ) + } + + var b strings.Builder + fmt.Fprintf(&b, `You are Klaus, a Gitea automation user (account name: "Klaus"). A webhook just woke you up. + +# Context + +- Repository: %s (default branch: %s) +- Event: %s / action=%s +- Triggered by: @%s +- Why you woke: %s on %s +- Source link: %s + +## Triggering content + +`+"```"+` +%s +`+"```"+` + +# Working environment + +- The repository is cloned at your current working directory (`+"`%s`"+`).%s +- Your SSH key is configured on Gitea: plain `+"`git push origin `"+` works. +- The `+"`gitea`"+` MCP server is connected. Prefer its `+"`mcp__gitea__*`"+` tools over curl/REST for all Gitea API interactions (reading issues, posting comments, opening PRs, etc.). +- You may run shell commands, edit files, read code — whatever the task needs. + +# How to respond + +1. Read the triggering content carefully and decide what is actually being asked. +2. If it's a question, clarification, review, or anything that does NOT require code changes: + - Post a comment on the originating %s using the gitea MCP. + - Keep it concise and helpful. Sign off as Klaus. +3. If it requires code changes: + a. Create a branch off the current HEAD named `+"`klaus/`"+`. + b. Make the changes locally. Run the project's tests/build if they exist and are quick. + c. Commit with a clear message. If git complains about identity, use: + `+"`git -c user.name=Klaus -c user.email=klaus@bot.local commit ...`"+` + d. Push: `+"`git push -u origin `"+`. + e. Open a pull request via the gitea MCP targeting `+"`%s`"+`. Reference the originating issue/comment in the PR body (e.g. "Closes #%d" if it resolves the issue). + f. Post a short comment on the originating %s linking to the new PR. + +# Constraints + +- Do not act on anything beyond what the triggering content actually asked for. +- Never push to the default branch directly. +- Do not write "@Klaus" anywhere in comments or PR descriptions — it would cause a feedback loop. +- If the request is ambiguous or out of scope, post a comment asking for clarification rather than guessing. +- Be terse. Engineers reading this don't need preamble. + +Now respond to the event. +`, + ev.Repository.FullName, + ev.Repository.DefaultBranch, + job.EventType, + ev.Action, + ev.Sender.Name(), + tr.Reason, + what, + tr.HTMLURL, + strings.TrimSpace(tr.Body), + workDir, + branchNote, + what, + ev.Repository.DefaultBranch, + tr.Number, + what, + ) + return b.String() +} diff --git a/worker.go b/worker.go new file mode 100644 index 0000000..d5f3769 --- /dev/null +++ b/worker.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type Job struct { + EventType string + Event *GiteaEvent + Trigger *Trigger +} + +// Worker runs jobs serially from an in-memory FIFO queue. One Claude session +// at a time — keeps logs readable, avoids racing on git workdirs, and bounds +// API spend. +type Worker struct { + cfg Config + queue chan Job +} + +func NewWorker(cfg Config) *Worker { + w := &Worker{cfg: cfg, queue: make(chan Job, 128)} + go w.run() + return w +} + +func (w *Worker) Enqueue(j Job) bool { + select { + case w.queue <- j: + return true + default: + log.Printf("[queue] FULL — dropping %s on %s", j.EventType, j.Event.Repository.FullName) + return false + } +} + +func (w *Worker) run() { + for job := range w.queue { + start := time.Now() + tag := fmt.Sprintf("%s#%d", job.Event.Repository.FullName, job.Trigger.Number) + log.Printf("[job %s] starting (event=%s reason=%s)", tag, job.EventType, job.Trigger.Reason) + if err := w.handle(job); err != nil { + log.Printf("[job %s] FAILED after %s: %v", tag, time.Since(start), err) + } else { + log.Printf("[job %s] done in %s", tag, time.Since(start)) + } + } +} + +func (w *Worker) handle(job Job) error { + dir, err := os.MkdirTemp(w.cfg.WorkspaceRoot, "klaus-*") + if err != nil { + return fmt.Errorf("mkdtemp: %w", err) + } + preserve := false + defer func() { + if preserve { + log.Printf("[workspace] preserved at %s", dir) + return + } + _ = os.RemoveAll(dir) + }() + + repoDir := filepath.Join(dir, "repo") + sshURL := job.Event.Repository.SSHURL + if sshURL == "" { + return fmt.Errorf("repository has no ssh_url; cannot clone") + } + if err := runShell(dir, "git", "clone", sshURL, repoDir); err != nil { + return fmt.Errorf("clone: %w", err) + } + + // For PR-related triggers, check out the PR head so Klaus sees the right code. + if job.Trigger.IsPR && job.Trigger.BranchHint != "" { + if err := runShell(repoDir, "git", "fetch", "origin", job.Trigger.BranchHint); err != nil { + log.Printf("[job] warn: fetch %s: %v", job.Trigger.BranchHint, err) + } else if err := runShell(repoDir, "git", "checkout", job.Trigger.BranchHint); err != nil { + log.Printf("[job] warn: checkout %s: %v", job.Trigger.BranchHint, err) + } + } + + prompt := buildPrompt(job, repoDir) + + ctx, cancel := context.WithTimeout(context.Background(), w.cfg.JobTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "claude", + "-p", prompt, + "--permission-mode", "bypassPermissions", + ) + cmd.Dir = repoDir + cmd.Env = append(os.Environ(), + // Don't let Klaus's per-job sessions pollute the klaus-bot project memory. + "CLAUDE_CODE_AUTO_MEMORY=0", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + preserve = true + return fmt.Errorf("claude: %w", err) + } + return nil +} + +// runShell runs a command with logged invocation; on failure the combined +// output is returned in the error so the operator can see what went wrong. +func runShell(dir, name string, args ...string) error { + log.Printf("$ (cwd=%s) %s %s", dir, name, strings.Join(args, " ")) + cmd := exec.Command(name, args...) + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, out.String()) + } + return nil +}