Files
superwork/tui/internal/webhook/server.go
T
2026-06-23 05:02:15 +08:00

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
}