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 }