This commit is contained in:
2026-05-28 19:49:29 +00:00
commit 0bfe0c314d
7 changed files with 766 additions and 0 deletions
+244
View File
@@ -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
}
+153
View File
@@ -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)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module klaus-bot
go 1.26.3
Executable
BIN
View File
Binary file not shown.
+141
View File
@@ -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)"
}
+99
View File
@@ -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 <branch>`"+` 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/<short-kebab-slug>`"+`.
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 <branch>`"+`.
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()
}
+126
View File
@@ -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
}