feat(review-bot): review every repository that requests a review
REPOS is gone. The webhook takes a review request from any repository, and the sweep asks Gitea for every open pull request that requests a review from the bot (issues/search review_requested=true), so a repo needs no webhook and no config entry: requesting review-bot is enough. The sweep runs every 5 minutes, since it is now one search plus two reads per requested PR. A head whose review failed is left to the next request or FAILURE_COOLDOWN (1h), so a failing review is not rerun on every sweep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,6 @@ spec:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
env:
|
||||
- name: REPOS
|
||||
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
|
||||
- name: GITEA_URL
|
||||
value: https://gitea.renner.uno
|
||||
- name: AGENT_NAME
|
||||
|
||||
+14
-21
@@ -48,12 +48,14 @@ type Config struct {
|
||||
TypeSafeKey string
|
||||
TypeSafeModel string
|
||||
|
||||
Repos []string
|
||||
Workers int
|
||||
QueueSize int
|
||||
Sweep time.Duration
|
||||
ReviewTimeout time.Duration
|
||||
ShutdownWait time.Duration
|
||||
Workers int
|
||||
QueueSize int
|
||||
// Sweep is how often the bot looks for requested reviews in every repository it can read.
|
||||
Sweep time.Duration
|
||||
// FailureCooldown keeps the sweep from rerunning a failed review of the same head.
|
||||
FailureCooldown time.Duration
|
||||
ReviewTimeout time.Duration
|
||||
ShutdownWait time.Duration
|
||||
// A review that runs into a rate limit or a gateway failure is asked again after a wait.
|
||||
RateLimitRetries int
|
||||
RateLimitWait time.Duration
|
||||
@@ -157,11 +159,12 @@ func loadConfig(getenv func(string) string, readFile func(string) ([]byte, error
|
||||
TypeSafeKey: optional("TYPESAFE_API_KEY"),
|
||||
TypeSafeModel: text("TYPESAFE_MODEL", "jev-latest"),
|
||||
|
||||
Workers: number("WORKERS", 1),
|
||||
QueueSize: number("QUEUE_SIZE", 32),
|
||||
Sweep: duration("SWEEP_INTERVAL", 30*time.Minute),
|
||||
ReviewTimeout: duration("REVIEW_TIMEOUT", 25*time.Minute),
|
||||
ShutdownWait: duration("SHUTDOWN_WAIT", 9*time.Minute),
|
||||
Workers: number("WORKERS", 1),
|
||||
QueueSize: number("QUEUE_SIZE", 32),
|
||||
Sweep: duration("SWEEP_INTERVAL", 5*time.Minute),
|
||||
FailureCooldown: duration("FAILURE_COOLDOWN", time.Hour),
|
||||
ReviewTimeout: duration("REVIEW_TIMEOUT", 25*time.Minute),
|
||||
ShutdownWait: duration("SHUTDOWN_WAIT", 9*time.Minute),
|
||||
|
||||
RateLimitRetries: number("RATE_LIMIT_RETRIES", 3),
|
||||
RateLimitWait: duration("RATE_LIMIT_WAIT", time.Minute),
|
||||
@@ -204,15 +207,5 @@ func loadConfig(getenv func(string) string, readFile func(string) ([]byte, error
|
||||
errs = append(errs, fmt.Errorf("SELECTOR must be empty or typesafe, got %q", c.Selector))
|
||||
}
|
||||
|
||||
for _, r := range list("REPOS", nil) {
|
||||
if strings.Count(r, "/") != 1 {
|
||||
errs = append(errs, fmt.Errorf("REPOS entry %q is not owner/repo", r))
|
||||
continue
|
||||
}
|
||||
c.Repos = append(c.Repos, r)
|
||||
}
|
||||
if len(c.Repos) == 0 {
|
||||
errs = append(errs, errors.New("REPOS is empty"))
|
||||
}
|
||||
return c, errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func noFiles(string) ([]byte, error) { return nil, errors.New("no such file") }
|
||||
func baseEnv() map[string]string {
|
||||
return map[string]string{
|
||||
"REVIEW_TOKEN": "t", "WEBHOOK_SECRET": "s", "MISTRAL_API_KEY": "k",
|
||||
"CONNECTOR_ID": "c1", "REPOS": "konairius/homelab, konairius/shelly-manager",
|
||||
"CONNECTOR_ID": "c1",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,14 @@ func TestLoadConfigDefaults(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Listen != ":8080" || c.GiteaURL != "https://gitea.renner.uno" || c.BotLogin != "review-bot" ||
|
||||
c.Workers != 1 || c.Sweep != 30*time.Minute || c.RateLimitRetries != 3 || c.RateLimitWait != time.Minute ||
|
||||
c.Workers != 1 || c.Sweep != 5*time.Minute || c.FailureCooldown != time.Hour ||
|
||||
c.RateLimitRetries != 3 || c.RateLimitWait != time.Minute ||
|
||||
c.Selector != "" || c.MCPURL != "https://gitea-mcp.renner.uno/mcp" || c.AnthropicEffort != "medium" || c.SelectorMinConfidence != 0.6 {
|
||||
t.Errorf("unexpected defaults: %+v", c)
|
||||
}
|
||||
if len(c.Models) != 1 || c.Models[0].ID() != "mistral:mistral-large-4-0" {
|
||||
t.Errorf("models: %+v", c.Models)
|
||||
}
|
||||
if len(c.Repos) != 2 || c.Repos[1] != "konairius/shelly-manager" {
|
||||
t.Errorf("repos: %v", c.Repos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigSecretFromFile(t *testing.T) {
|
||||
@@ -47,13 +45,14 @@ func TestLoadConfigSecretFromFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadConfigErrors(t *testing.T) {
|
||||
_, err := loadConfig(env(map[string]string{"WORKERS": "0", "REPOS": "nonsense", "SWEEP_INTERVAL": "soon",
|
||||
"SELECTOR": "dice"}), noFiles)
|
||||
_, err := loadConfig(env(map[string]string{"WORKERS": "0", "SWEEP_INTERVAL": "soon", "FAILURE_COOLDOWN": "never",
|
||||
"SELECTOR": "dice", "SELECTOR_MIN_CONFIDENCE": "2"}), noFiles)
|
||||
if err == nil {
|
||||
t.Fatal("expected errors")
|
||||
}
|
||||
for _, want := range []string{"REVIEW_TOKEN is not set", "WEBHOOK_SECRET is not set", "no model of MODELS is usable",
|
||||
"MISTRAL_API_KEY is not set", "WORKERS must be", "SWEEP_INTERVAL must be", `REPOS entry "nonsense"`, "SELECTOR must be"} {
|
||||
"MISTRAL_API_KEY is not set", "WORKERS must be", "SWEEP_INTERVAL must be", "FAILURE_COOLDOWN must be",
|
||||
"SELECTOR must be", "SELECTOR_MIN_CONFIDENCE must be"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("missing %q in: %v", want, err)
|
||||
}
|
||||
|
||||
+22
-4
@@ -96,10 +96,28 @@ func (g *Gitea) PR(ctx context.Context, slug string, n int) (PR, error) {
|
||||
return pr, err
|
||||
}
|
||||
|
||||
func (g *Gitea) OpenPRs(ctx context.Context, slug string) ([]PR, error) {
|
||||
var prs []PR
|
||||
err := g.do(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", slug), nil, &prs)
|
||||
return prs, err
|
||||
// Requested lists every open pull request, in any repository, that asks the token's user for a
|
||||
// review, as (repository, number) pairs.
|
||||
func (g *Gitea) Requested(ctx context.Context) ([]job, error) {
|
||||
var out []job
|
||||
for page := 1; ; page++ {
|
||||
var issues []struct {
|
||||
Number int `json:"number"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
path := fmt.Sprintf("/repos/issues/search?type=pulls&state=open&review_requested=true&limit=50&page=%d", page)
|
||||
if err := g.do(ctx, http.MethodGet, path, nil, &issues); err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, is := range issues {
|
||||
out = append(out, job{slug: is.Repository.FullName, num: is.Number})
|
||||
}
|
||||
if len(issues) < 50 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gitea) Reviews(ctx context.Context, slug string, n int) ([]Review, error) {
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ func run(ctx context.Context, cfg Config, log *slog.Logger) error {
|
||||
}
|
||||
failed := make(chan error, 1)
|
||||
go func() { failed <- srv.ListenAndServe() }()
|
||||
log.Info("listening", "addr", cfg.Listen, "repos", cfg.Repos)
|
||||
log.Info("listening", "addr", cfg.Listen, "sweep", cfg.Sweep.String())
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
+23
-15
@@ -354,25 +354,33 @@ func (q *Queue) Stop(wait time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep queues every open PR of the configured repositories that has no review at its head
|
||||
// commit. It is the safety net for webhook deliveries that were lost.
|
||||
// Sweep queues every open PR, in any repository, that requests a review from the bot and needs
|
||||
// one. It reviews repositories without a webhook and catches lost deliveries. A head whose review
|
||||
// failed is left alone for FailureCooldown, so a failing review is not rerun on every sweep.
|
||||
func (r *Reviewer) Sweep(ctx context.Context, q *Queue) {
|
||||
for _, slug := range r.cfg.Repos {
|
||||
prs, err := r.gitea.OpenPRs(ctx, slug)
|
||||
defer r.metrics.Inc("sweeps_total")
|
||||
jobs, err := r.gitea.Requested(ctx)
|
||||
if err != nil {
|
||||
r.log.Error("sweep: cannot list requested reviews", "err", err)
|
||||
}
|
||||
for _, j := range jobs {
|
||||
pr, err := r.gitea.PR(ctx, j.slug, j.num)
|
||||
if err != nil {
|
||||
r.log.Error("sweep: cannot list pull requests", "repo", slug, "err", err)
|
||||
r.log.Error("sweep: cannot read the pull request", "pr", key(j.slug, j.num), "err", err)
|
||||
continue
|
||||
}
|
||||
for _, pr := range prs {
|
||||
reviews, err := r.gitea.Reviews(ctx, slug, pr.Number)
|
||||
if err != nil {
|
||||
r.log.Error("sweep: cannot read reviews", "pr", key(slug, pr.Number), "err", err)
|
||||
continue
|
||||
}
|
||||
if eligible(pr, reviews, r.cfg.BotLogin) == "ok" {
|
||||
r.log.Info("sweep: queued", "pr", key(slug, pr.Number), "result", q.Enqueue(slug, pr.Number, pr.Head.SHA))
|
||||
}
|
||||
reviews, err := r.gitea.Reviews(ctx, j.slug, j.num)
|
||||
if err != nil {
|
||||
r.log.Error("sweep: cannot read reviews", "pr", key(j.slug, j.num), "err", err)
|
||||
continue
|
||||
}
|
||||
if eligible(pr, reviews, r.cfg.BotLogin) != "ok" {
|
||||
continue
|
||||
}
|
||||
if st, ok := r.status.Get(j.slug, j.num); ok && st.Phase == PhaseFailed && st.Head == pr.Head.SHA &&
|
||||
time.Since(st.Since) < r.cfg.FailureCooldown {
|
||||
continue
|
||||
}
|
||||
r.log.Info("sweep: queued", "pr", key(j.slug, j.num), "result", q.Enqueue(j.slug, j.num, pr.Head.SHA))
|
||||
}
|
||||
r.metrics.Inc("sweeps_total")
|
||||
}
|
||||
|
||||
@@ -118,8 +118,17 @@ func newFixture(t *testing.T) *fixture {
|
||||
_, _ = io.WriteString(w, prJSON)
|
||||
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
|
||||
_, _ = io.WriteString(w, f.reviews)
|
||||
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls":
|
||||
_, _ = io.WriteString(w, "["+prJSON+"]")
|
||||
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/issues/search":
|
||||
q := r.URL.Query()
|
||||
if q.Get("type") != "pulls" || q.Get("state") != "open" || q.Get("review_requested") != "true" {
|
||||
http.Error(w, "unexpected search", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !f.requested || q.Get("page") != "1" {
|
||||
_, _ = io.WriteString(w, "[]")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `[{"number":7,"repository":{"full_name":"konairius/homelab"}}]`)
|
||||
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
|
||||
var in map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
@@ -203,7 +212,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
GiteaURL: f.gitea.URL, BotLogin: "review-bot", ReviewToken: "review-token", WebhookSecret: "hook-secret",
|
||||
MistralURL: f.mistral.URL, MistralKey: "mistral-key", AgentName: "pr-review",
|
||||
Models: []Model{{Backend: "mistral", Name: "mistral-large-4-0"}},
|
||||
ConnectorID: "c1", Repos: []string{"konairius/homelab"}, Workers: 1, QueueSize: 4,
|
||||
ConnectorID: "c1", Workers: 1, QueueSize: 4, FailureCooldown: time.Hour,
|
||||
ReviewTimeout: 5 * time.Second, RateLimitRetries: 2, RateLimitWait: time.Millisecond,
|
||||
}
|
||||
f.metrics = newMetrics()
|
||||
@@ -473,7 +482,7 @@ func TestHook(t *testing.T) {
|
||||
{"opened alone starts nothing", "pull_request", openedSig, openedBody, 200},
|
||||
{"a push alone starts nothing", "pull_request", syncSig, syncBody, 200},
|
||||
{"review requested from someone else", "pull_request", otherSig, otherBody, 200},
|
||||
{"repository that is not configured", "pull_request", repoSig, repoBody, 200},
|
||||
{"any repository", "pull_request", repoSig, repoBody, 202},
|
||||
{"payload without a number", "pull_request", sign("hook-secret", []byte(noNum)), noNum, 400},
|
||||
{"payload that is not json", "pull_request", sign("hook-secret", []byte("nope")), "nope", 400},
|
||||
}
|
||||
@@ -523,6 +532,26 @@ func TestSweepQueuesOnlyWhatNeedsAReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A head whose review just failed is not rerun by the sweep; a new head is.
|
||||
func TestSweepLeavesARecentFailureAlone(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.rev.status.Set("konairius/homelab", 7, "abc123", PhaseFailed, "boom", "")
|
||||
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
|
||||
f.rev.Sweep(context.Background(), q)
|
||||
if len(q.jobs) != 0 {
|
||||
t.Fatalf("queued %d right after a failure, want 0", len(q.jobs))
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.head = "def456"
|
||||
f.mu.Unlock()
|
||||
q2 := newQueue(f.rev, 4, f.rev.log, f.metrics)
|
||||
f.rev.Sweep(context.Background(), q2)
|
||||
if len(q2.jobs) != 1 {
|
||||
t.Errorf("queued %d for a new head after a failure, want 1", len(q2.jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpoints(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
s := &Server{cfg: f.cfg, queue: newQueue(f.rev, 1, f.rev.log, f.metrics), reviewer: f.rev, metrics: f.metrics, log: f.rev.log}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -136,10 +135,6 @@ func (s *Server) hook(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user