Every phase of a pull request's review is now visible in two places:
- a commit status "review-bot" on the reviewed head: pending while
queued, running or waiting to retry; success once reviewed (with the
verdict) or skipped; error with the reason when the review failed;
warning when new commits superseded the reviewed head. The statuses
are posted one at a time in order, so a later phase never lands before
an earlier one, and never block the queue.
- GET /status and GET /status/{owner}/{repo}/{n}: the in-memory state
of every PR seen since start, with the queue position while queued.
Finished entries are dropped after a day.
Until now a failed review posted nothing, so a PR whose review failed
looked the same as one still waiting.
Also gofmt report_test.go, which the CI gofmt check flags on master.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
4.3 KiB
Go
148 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
)
|
|
|
|
// Server is the HTTP side: Gitea's webhook plus the health and metrics endpoints.
|
|
type Server struct {
|
|
cfg Config
|
|
queue *Queue
|
|
reviewer *Reviewer
|
|
metrics *Metrics
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (s *Server) routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /hook", s.hook)
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, "ok\n")
|
|
})
|
|
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
|
|
if !s.reviewer.Ready() {
|
|
http.Error(w, "the review agent is not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, "ready\n")
|
|
})
|
|
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
|
s.metrics.Render(w)
|
|
})
|
|
mux.HandleFunc("GET /status", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, s.reviewer.status.List())
|
|
})
|
|
mux.HandleFunc("GET /status/{owner}/{repo}/{number}", func(w http.ResponseWriter, r *http.Request) {
|
|
n, err := strconv.Atoi(r.PathValue("number"))
|
|
if err != nil {
|
|
http.Error(w, "the pull request number is not a number", http.StatusBadRequest)
|
|
return
|
|
}
|
|
st, ok := s.reviewer.status.Get(r.PathValue("owner")+"/"+r.PathValue("repo"), n)
|
|
if !ok {
|
|
http.Error(w, "not tracked since the bot started", http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, st)
|
|
})
|
|
return mux
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
type hookPayload struct {
|
|
Action string `json:"action"`
|
|
Number int `json:"number"`
|
|
PullRequest struct {
|
|
Number int `json:"number"`
|
|
Head struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"head"`
|
|
} `json:"pull_request"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
} `json:"repository"`
|
|
}
|
|
|
|
// validSignature checks Gitea's X-Gitea-Signature: the hex HMAC-SHA256 of the raw body.
|
|
func validSignature(secret string, body []byte, signature string) bool {
|
|
want, err := hex.DecodeString(signature)
|
|
if err != nil || len(want) == 0 {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
return hmac.Equal(mac.Sum(nil), want)
|
|
}
|
|
|
|
func (s *Server) hook(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20))
|
|
if err != nil {
|
|
s.metrics.Inc("webhooks_total", "result", "rejected")
|
|
http.Error(w, "unreadable body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !validSignature(s.cfg.WebhookSecret, body, r.Header.Get("X-Gitea-Signature")) {
|
|
s.metrics.Inc("webhooks_total", "result", "rejected")
|
|
http.Error(w, "bad signature", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
ignore := func(why string) {
|
|
s.metrics.Inc("webhooks_total", "result", "ignored")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "ignored: "+why+"\n")
|
|
}
|
|
if ev := r.Header.Get("X-Gitea-Event"); ev != "pull_request" {
|
|
ignore("event " + ev)
|
|
return
|
|
}
|
|
var p hookPayload
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
s.metrics.Inc("webhooks_total", "result", "rejected")
|
|
http.Error(w, "bad payload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Gitea says "synchronized" for new commits on an open PR.
|
|
if !slices.Contains([]string{"opened", "reopened", "synchronize", "synchronized"}, p.Action) {
|
|
ignore("action " + p.Action)
|
|
return
|
|
}
|
|
n := p.Number
|
|
if n == 0 {
|
|
n = p.PullRequest.Number
|
|
}
|
|
slug := p.Repository.FullName
|
|
if n == 0 || slug == "" {
|
|
s.metrics.Inc("webhooks_total", "result", "rejected")
|
|
http.Error(w, "no repository or pull request number", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !slices.Contains(s.cfg.Repos, slug) {
|
|
ignore("repository " + slug + " is not configured")
|
|
return
|
|
}
|
|
switch res := s.queue.Enqueue(slug, n, p.PullRequest.Head.SHA); res {
|
|
case "full":
|
|
s.metrics.Inc("webhooks_total", "result", "dropped")
|
|
s.log.Warn("queue full, left to the sweep", "pr", key(slug, n))
|
|
http.Error(w, "busy", http.StatusTooManyRequests)
|
|
default:
|
|
s.metrics.Inc("webhooks_total", "result", res)
|
|
s.log.Info("webhook", "pr", key(slug, n), "action", p.Action, "result", res)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = io.WriteString(w, res+"\n")
|
|
}
|
|
}
|