245 lines
7.7 KiB
Go
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
|
|
}
|