11
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user