first commit
This commit is contained in:
116
internal/webhook/webhook.go
Normal file
116
internal/webhook/webhook.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user