initial
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user