// Package webhook handles incoming Gitea pull_request webhook events. package webhook import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "ridgwaysystems.org/gitea-pr-reviewer/internal/deepseek" "ridgwaysystems.org/gitea-pr-reviewer/internal/gitea" ) // Handler processes Gitea pull_request webhooks. type Handler struct { Gitea *gitea.Client DeepSeek *deepseek.Client WebhookSecret string } // prPayload is the subset of the Gitea pull_request webhook payload we need. type prPayload struct { Action string `json:"action"` Number int `json:"number"` Repository struct { Name string `json:"name"` Owner struct { Login string `json:"login"` } `json:"owner"` } `json:"repository"` PullRequest struct { Title string `json:"title"` } `json:"pull_request"` } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } if h.WebhookSecret != "" && !verifySignature(body, r.Header.Get("X-Gitea-Signature"), h.WebhookSecret) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } if r.Header.Get("X-Gitea-Event") != "pull_request" { w.WriteHeader(http.StatusNoContent) return } var p prPayload if err := json.Unmarshal(body, &p); err != nil { http.Error(w, "bad payload", http.StatusBadRequest) return } // Only review when a PR is first opened, when new commits are pushed, // or when a closed PR is reopened. switch p.Action { case "opened", "synchronize", "reopened": default: w.WriteHeader(http.StatusNoContent) return } // Respond immediately so Gitea doesn't time out waiting. w.WriteHeader(http.StatusAccepted) go h.review(p) } func (h *Handler) review(p prPayload) { owner := p.Repository.Owner.Login repo := p.Repository.Name number := p.Number title := p.PullRequest.Title log.Printf("reviewing %s/%s#%d %q (action: %s)", owner, repo, number, title, p.Action) diff, err := h.Gitea.FetchDiff(owner, repo, number) if err != nil { log.Printf("fetch diff %s/%s#%d: %v", owner, repo, number, err) return } if diff == "" { log.Printf("empty diff for %s/%s#%d, skipping", owner, repo, number) return } review, err := h.DeepSeek.Review(title, diff) if err != nil { log.Printf("deepseek %s/%s#%d: %v", owner, repo, number, err) return } comment := "## AI Code Review\n\n" + review + "\n\n---\n*Reviewed by deepseek-coder*" if err := h.Gitea.PostComment(owner, repo, number, comment); err != nil { log.Printf("post comment %s/%s#%d: %v", owner, repo, number, err) return } log.Printf("review posted for %s/%s#%d", owner, repo, number) } // verifySignature checks the HMAC-SHA256 signature Gitea sends in X-Gitea-Signature. func verifySignature(body []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) }