238 lines
5.4 KiB
Go
238 lines
5.4 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Event is the discriminated union type for webhook events.
|
|
type Event interface{ webhookEvent() }
|
|
|
|
type PrEvent struct {
|
|
IssueNumber int // 0 on canonical /webhook route; set on /webhook/:N
|
|
Action string
|
|
PR string
|
|
Branch string
|
|
HTMLUrl string
|
|
Title string
|
|
Body string
|
|
Raw any
|
|
}
|
|
|
|
type IssueEvent struct {
|
|
Action string
|
|
IssueNumber int
|
|
Title string
|
|
Body string
|
|
HTMLUrl string
|
|
Raw any
|
|
}
|
|
|
|
type IssueCommentEvent struct {
|
|
Action string
|
|
IssueNumber int
|
|
PRNumber int // 0 when comment is not on a PR
|
|
CommentBody string
|
|
CommentHTMLUrl string
|
|
Raw any
|
|
}
|
|
|
|
type PushEvent struct {
|
|
Branch string
|
|
Raw any
|
|
}
|
|
|
|
func (PrEvent) webhookEvent() {}
|
|
func (IssueEvent) webhookEvent() {}
|
|
func (IssueCommentEvent) webhookEvent() {}
|
|
func (PushEvent) webhookEvent() {}
|
|
|
|
// Server parses Gitea webhooks and calls onEvent for each accepted payload.
|
|
type Server struct {
|
|
onEvent func(Event)
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
var legacyRouteRe = regexp.MustCompile(`^/webhook/(\d+)$`)
|
|
|
|
func NewServer(onEvent func(Event)) *Server {
|
|
s := &Server{onEvent: onEvent}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", s.handle)
|
|
s.mux = mux
|
|
return s
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
// Start begins listening on addr and stops when ctx is cancelled.
|
|
func (s *Server) Start(ctx context.Context, addr string) error {
|
|
srv := &http.Server{Addr: addr, Handler: s}
|
|
go func() {
|
|
<-ctx.Done()
|
|
srv.Shutdown(context.Background()) //nolint
|
|
}()
|
|
return srv.ListenAndServe()
|
|
}
|
|
|
|
func jsonResp(w http.ResponseWriter, code int, body map[string]any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(body) //nolint
|
|
}
|
|
|
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
jsonResp(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method_not_allowed"})
|
|
return
|
|
}
|
|
|
|
issueNumber := 0
|
|
path := r.URL.Path
|
|
if m := legacyRouteRe.FindStringSubmatch(path); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
issueNumber = n
|
|
} else if path != "/webhook" {
|
|
jsonResp(w, http.StatusNotFound, map[string]any{"ok": false, "error": "not_found"})
|
|
return
|
|
}
|
|
|
|
raw, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
jsonResp(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "read_error"})
|
|
return
|
|
}
|
|
|
|
var payload any
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
jsonResp(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"})
|
|
return
|
|
}
|
|
|
|
eventHeader := r.Header.Get("X-Gitea-Event")
|
|
if ev := parseEvent(eventHeader, issueNumber, payload); ev != nil {
|
|
s.onEvent(ev)
|
|
} else {
|
|
slog.Info("webhook: event not matched or missing required fields", "header", eventHeader)
|
|
}
|
|
|
|
jsonResp(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func parseEvent(eventHeader string, issueNumber int, raw any) Event {
|
|
obj, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
switch eventHeader {
|
|
case "push":
|
|
ref, _ := obj["ref"].(string)
|
|
branch := strings.TrimPrefix(ref, "refs/heads/")
|
|
if branch == "" || branch == ref {
|
|
return nil
|
|
}
|
|
return PushEvent{Branch: branch, Raw: raw}
|
|
|
|
case "issue_comment":
|
|
issue, _ := obj["issue"].(map[string]any)
|
|
comment, _ := obj["comment"].(map[string]any)
|
|
if issue == nil || comment == nil {
|
|
return nil
|
|
}
|
|
num := toInt(issue["number"])
|
|
if num == 0 {
|
|
return nil
|
|
}
|
|
prNumber := 0
|
|
if pr, exists := issue["pull_request"]; exists && pr != nil {
|
|
if _, isMap := pr.(map[string]any); isMap {
|
|
prNumber = num
|
|
}
|
|
}
|
|
action, _ := obj["action"].(string)
|
|
commentBody, _ := comment["body"].(string)
|
|
commentHTMLUrl, _ := comment["html_url"].(string)
|
|
return IssueCommentEvent{
|
|
Action: action,
|
|
IssueNumber: num,
|
|
PRNumber: prNumber,
|
|
CommentBody: commentBody,
|
|
CommentHTMLUrl: commentHTMLUrl,
|
|
Raw: raw,
|
|
}
|
|
|
|
case "issues":
|
|
issue, _ := obj["issue"].(map[string]any)
|
|
if issue == nil {
|
|
return nil
|
|
}
|
|
num := toInt(issue["number"])
|
|
if num == 0 {
|
|
return nil
|
|
}
|
|
action, _ := obj["action"].(string)
|
|
htmlUrl, _ := issue["html_url"].(string)
|
|
title, _ := issue["title"].(string)
|
|
body, _ := issue["body"].(string)
|
|
return IssueEvent{
|
|
Action: action,
|
|
IssueNumber: num,
|
|
Title: title,
|
|
Body: body,
|
|
HTMLUrl: htmlUrl,
|
|
Raw: raw,
|
|
}
|
|
|
|
default: // "pull_request" or unknown → attempt PR parse
|
|
pr, _ := obj["pull_request"].(map[string]any)
|
|
if pr == nil {
|
|
return nil
|
|
}
|
|
num := toInt(pr["number"])
|
|
if num == 0 {
|
|
return nil
|
|
}
|
|
htmlUrl, _ := pr["html_url"].(string)
|
|
head, _ := pr["head"].(map[string]any)
|
|
var branch string
|
|
if head != nil {
|
|
branch, _ = head["ref"].(string)
|
|
}
|
|
if branch == "" || htmlUrl == "" {
|
|
return nil
|
|
}
|
|
action, _ := obj["action"].(string)
|
|
title, _ := pr["title"].(string)
|
|
body, _ := pr["body"].(string)
|
|
return PrEvent{
|
|
IssueNumber: issueNumber,
|
|
Action: action,
|
|
PR: strconv.Itoa(num),
|
|
Branch: branch,
|
|
HTMLUrl: htmlUrl,
|
|
Title: title,
|
|
Body: body,
|
|
Raw: raw,
|
|
}
|
|
}
|
|
}
|
|
|
|
func toInt(v any) int {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int(x)
|
|
case int:
|
|
return x
|
|
}
|
|
return 0
|
|
}
|