Files
homelab/review-bot/server.go
claude-botandClaude Opus 5.5 5ccb3ceaa6 feat(review-bot): report queued/running/retrying/failed per PR
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>
2026-10-16 08:12:42 +02:00

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")
}
}