Files
klaus-bot/events.go
T
2026-05-28 19:49:29 +00:00

245 lines
7.7 KiB
Go

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
}