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 }