Files
claude-botandClaude Opus 5.5 7f29ebb878
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
chore: follow homelab to konairius/homelab
Flux source URL, flux-git rotator verify repo, review-bot REPOS, pipeline API,
clone and push URLs, the heating-can source label and the review-bot and
kuma-sync Go module paths. Merge right after the transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:43:23 +02:00

628 lines
23 KiB
Go

package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fixture runs the service against fake Gitea and Mistral servers.
type fixture struct {
t *testing.T
gitea *httptest.Server
mistral *httptest.Server
cfg Config
rev *Reviewer
metrics *Metrics
status *Tracker
mu sync.Mutex
prState string // state of PR 7 on every read after the first
title string // title of PR 7
reviews string // JSON array returned for PR 7's reviews
answer string // JSON returned for a conversation
posted []map[string]string
statuses []map[string]string // commit statuses set, with "sha" added
agentCall []string
agents string // JSON array returned for the agent list
convStatus []int // statuses for the next conversations; 429 simulates the connector rate limit
answers []string // answers for the next conversations, then answer
convCalls int
retryAfter string // Retry-After header sent with a simulated 429
head string // head commit of PR 7
pushAfter string // when set, the next conversation answer is followed by a push of this head
// breakStreams breaks the next conversation streams off after their first event.
breakStreams int
history string // JSON returned for a conversation's history; "" answers 404
historyCalls int
streamError string // when set, conversations end in a conversation.response.error event
mistralBackend *Mistral
}
// sse renders a conversation answer ({"outputs":[...]}) as the event stream Mistral sends.
func sse(t *testing.T, answer string, broken bool, streamErr string) string {
var c struct {
Outputs []map[string]json.RawMessage `json:"outputs"`
}
if err := json.Unmarshal([]byte(answer), &c); err != nil {
t.Fatalf("bad answer fixture: %v", err)
}
var b strings.Builder
write := func(v map[string]any) {
data, _ := json.Marshal(v)
b.WriteString("event: " + v["type"].(string) + "\ndata: " + string(data) + "\n\n")
}
write(map[string]any{"type": "conversation.response.started", "conversation_id": "conv_1"})
if broken {
return b.String()
}
for i, o := range c.Outputs {
var typ string
_ = json.Unmarshal(o["type"], &typ)
switch typ {
case "tool.execution":
var name string
_ = json.Unmarshal(o["name"], &name)
write(map[string]any{"type": "tool.execution.started", "id": "t", "name": name, "arguments": "{}"})
case "message.output":
write(map[string]any{"type": "message.output.delta", "id": "m" + string(rune('0'+i)), "content": o["content"]})
}
}
if streamErr != "" {
write(map[string]any{"type": "conversation.response.error", "message": streamErr, "code": 3000})
return b.String()
}
write(map[string]any{"type": "conversation.response.done", "usage": map[string]int{"total_tokens": 1}})
return b.String()
}
const goodAnswer = `{"outputs":[
{"type":"tool.execution","name":"conn_get_file_contents"},
{"type":"message.output","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"I looked.\n\n## Summary\n\nAdds a field.\n\n## 🔴 Blocking\n\n- a.yaml:3 is wrong.\n\n## 🟡 Suggestions\n\nNone.\n\n## Recommendation\n\nRequest changes — 1 blocking issue(s)\n\nVerdict: request-changes"}]}]}`
func newFixture(t *testing.T) *fixture {
f := &fixture{t: t, prState: "open", title: "feat: x", head: "abc123", reviews: "[]", answer: goodAnswer, agents: "[]"}
pr := func() string {
f.mu.Lock()
defer f.mu.Unlock()
return `{"number":7,"state":"` + f.prState + `","title":"` + f.title + `","additions":3,"deletions":1,"changed_files":1,` +
`"html_url":"https://gitea/pr/7","head":{"sha":"` + f.head + `"}}`
}
f.gitea = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token review-token" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
prJSON := pr()
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7":
_, _ = 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 == "POST" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
f.posted = append(f.posted, in)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"html_url":"https://gitea/pr/7#review"}`)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/"):
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/")
f.statuses = append(f.statuses, in)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, "{}")
default:
http.NotFound(w, r)
}
}))
f.mistral = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer mistral-key" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.Method == "GET" && r.URL.Path == "/agents":
_, _ = io.WriteString(w, f.agents)
case r.Method == "POST" && r.URL.Path == "/agents":
f.agentCall = append(f.agentCall, "POST")
_, _ = io.WriteString(w, `{"id":"ag_new","name":"pr-review","model":"mistral-large-4-0"}`)
case r.Method == "PATCH" && strings.HasPrefix(r.URL.Path, "/agents/"):
f.agentCall = append(f.agentCall, "PATCH "+strings.TrimPrefix(r.URL.Path, "/agents/"))
_, _ = io.WriteString(w, `{"name":"pr-review","model":"mistral-large-4-0"}`)
case r.Method == "POST" && r.URL.Path == "/conversations":
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
if in["agent_id"] != "ag_1" || in["stream"] != true || in["store"] != true ||
!strings.Contains(in["inputs"].(string), "pull request #7 of konairius/homelab") {
http.Error(w, "unexpected conversation request", http.StatusBadRequest)
return
}
f.convCalls++
if len(f.convStatus) > 0 {
status := f.convStatus[0]
f.convStatus = f.convStatus[1:]
if f.retryAfter != "" {
w.Header().Set("Retry-After", f.retryAfter)
}
http.Error(w, `{"detail":"Custom connector rate limit reached."}`, status)
return
}
answer := f.answer
if len(f.answers) > 0 {
answer, f.answers = f.answers[0], f.answers[1:]
}
broken := f.breakStreams > 0
if broken {
f.breakStreams--
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, sse(f.t, answer, broken, f.streamError))
if f.pushAfter != "" {
f.head, f.pushAfter = f.pushAfter, ""
}
case r.Method == "GET" && r.URL.Path == "/conversations/conv_1/history":
f.historyCalls++
if f.history == "" {
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, f.history)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(f.gitea.Close)
t.Cleanup(f.mistral.Close)
f.cfg = Config{
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,
ReviewTimeout: 5 * time.Second, RateLimitRetries: 2, RateLimitWait: time.Millisecond,
}
f.metrics = newMetrics()
gitea := &Gitea{base: f.gitea.URL, token: "review-token", http: http.DefaultClient}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
f.status = newTracker(gitea, log, f.metrics)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go f.status.Run(ctx)
f.mistralBackend = &Mistral{base: f.mistral.URL, key: "mistral-key", agentPrefix: "pr-review", connectorID: "c1",
http: http.DefaultClient, recoverTries: 2, recoverWait: time.Millisecond,
agents: map[string]string{"mistral-large-4-0": "ag_1"}}
f.rev = &Reviewer{
cfg: f.cfg,
gitea: gitea,
backends: map[string]Backend{"mistral": f.mistralBackend},
log: log,
metrics: f.metrics,
status: f.status,
}
f.rev.setReady("mistral", true)
return f
}
// commitStatuses waits for the commit statuses set so far and returns them as "sha state: description".
func (f *fixture) commitStatuses() []string {
f.status.Flush()
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, s := range f.statuses {
if s["context"] != "review-bot" {
f.t.Errorf("status context %q, want review-bot", s["context"])
}
out = append(out, s["sha"]+" "+s["state"]+": "+s["description"])
}
return out
}
func (f *fixture) postedReviews() []map[string]string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]map[string]string(nil), f.posted...)
}
func TestReviewPostsTheCleanedReport(t *testing.T) {
f := newFixture(t)
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.postedReviews()
if len(got) != 1 {
t.Fatalf("posted %d reviews, want 1", len(got))
}
p := got[0]
if p["event"] != "REQUEST_CHANGES" || p["commit_id"] != "abc123" {
t.Errorf("event/commit: %v", p)
}
for _, want := range []string{"model `mistral-large-4-0`", "commit `abc123`", "## 🔴 Blocking", "- a.yaml:3 is wrong.",
"## Recommendation", "<!-- pr-review:sha=abc123 -->"} {
if !strings.Contains(p["body"], want) {
t.Errorf("body lacks %q:\n%s", want, p["body"])
}
}
for _, unwanted := range []string{"I looked.", "Suggestions"} {
if strings.Contains(p["body"], unwanted) {
t.Errorf("body should not contain %q", unwanted)
}
}
}
func TestReviewSkipsWhatIsAlreadyReviewed(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if n := len(f.postedReviews()); n != 0 {
t.Errorf("posted %d reviews, want none", n)
}
}
// A pending review (an event Gitea did not know) is visible only to the bot: the PR is reviewed
// again and the approval goes out as APPROVED.
func TestPendingReviewIsReviewedAgain(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"PENDING","user":{"login":"review-bot"}}]`
f.answer = `{"outputs":[{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: approve"}]}`
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
if len(posted) != 1 || posted[0]["event"] != "APPROVED" {
t.Fatalf("want one APPROVED review, got %v", posted)
}
}
func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"tool.execution","name":"conn_create_or_update_file"},
{"type":"message.output","content":"## Summary\n\nx"}]}`
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "conn_create_or_update_file") {
t.Fatalf("want an error naming the tool, got %v", err)
}
if n := len(f.postedReviews()); n != 0 {
t.Errorf("posted %d reviews, want none", n)
}
}
func TestReviewWithoutAReportPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if n := len(f.postedReviews()); n != 0 {
t.Errorf("posted %d reviews, want none", n)
}
}
func TestReviewNeedsTheAgent(t *testing.T) {
f := newFixture(t)
f.rev.setReady("mistral", false)
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error while the agent is not ready")
}
if f.rev.Ready() {
t.Error("not ready without an agent")
}
}
func TestReviewAsksAgainAfterTheRateLimit(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429}
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 3 || len(f.postedReviews()) != 1 {
t.Errorf("conversations %d, reviews %d; want 3 and 1", f.convCalls, len(f.postedReviews()))
}
}
// Mistral's Retry-After replaces the guessed wait: with a guess of ten minutes this only finishes
// within the test's five seconds when the header is honoured.
func TestReviewHonoursRetryAfter(t *testing.T) {
f := newFixture(t)
f.rev.cfg.RateLimitWait = 10 * time.Minute
f.retryAfter = "0"
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
t.Errorf("conversations %d, reviews %d; want 2 and 1", f.convCalls, len(f.postedReviews()))
}
}
func TestLimitHeaders(t *testing.T) {
h := http.Header{}
h.Set("Retry-After", "7")
h.Set("X-RateLimit-Remaining", "0")
h.Set("X-Ratelimit-Limit-Tokens-Minute", "100")
h.Set("Content-Type", "application/json")
got := limitHeaders(h)
if len(got) != 3 || got["Retry-After"] != "7" || got["X-Ratelimit-Remaining"] != "0" {
t.Errorf("unexpected headers: %v", got)
}
}
func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429, 429, 429}
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "still failing") {
t.Fatalf("want a rate limit error, got %v", err)
}
if f.convCalls != 3 || len(f.postedReviews()) != 0 {
t.Errorf("conversations %d, reviews %d; want 3 and 0", f.convCalls, len(f.postedReviews()))
}
}
func TestOtherErrorsAreNotRetried(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{500}
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if f.convCalls != 1 {
t.Errorf("conversations %d, want 1", f.convCalls)
}
}
func TestEnsureAgent(t *testing.T) {
f := newFixture(t)
hash := promptHash(reviewPrompt)
f.agents = "[]"
a, err := f.mistralBackend.EnsureAgent(context.Background(), "pr-review", "mistral-large-4-0", "c1", reviewPrompt)
if err != nil || a.ID != "ag_new" {
t.Fatalf("create: %+v, %v", a, err)
}
f.mu.Lock()
f.agentCall = nil
f.agents = `[{"id":"ag_9","name":"pr-review","model":"mistral-large-4-0","description":"PR review, read-only Gitea connector. prompt sha256:` + hash + `"}]`
f.mu.Unlock()
a, err = f.mistralBackend.EnsureAgent(context.Background(), "pr-review", "mistral-large-4-0", "c1", reviewPrompt)
if err != nil || a.ID != "ag_9" || len(f.agentCall) != 0 {
t.Fatalf("unchanged agent must not be touched: %+v, %v, calls %v", a, err, f.agentCall)
}
f.mu.Lock()
f.agents = `[{"id":"ag_9","name":"pr-review","model":"mistral-large-4-0","description":"PR review, read-only Gitea connector. prompt sha256:old"}]`
f.mu.Unlock()
a, err = f.mistralBackend.EnsureAgent(context.Background(), "pr-review", "mistral-large-4-0", "c1", reviewPrompt)
if err != nil || a.ID != "ag_9" || len(f.agentCall) != 1 || f.agentCall[0] != "PATCH ag_9" {
t.Fatalf("drifted agent must be updated in place: %+v, %v, calls %v", a, err, f.agentCall)
}
}
func sign(secret string, body []byte) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write(body)
return hex.EncodeToString(m.Sum(nil))
}
func (f *fixture) hook(event, signature string, body string) *httptest.ResponseRecorder {
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
s := &Server{cfg: f.cfg, queue: q, reviewer: f.rev, metrics: f.metrics, log: f.rev.log}
req := httptest.NewRequest("POST", "/hook", strings.NewReader(body))
req.Header.Set("X-Gitea-Event", event)
if signature != "" {
req.Header.Set("X-Gitea-Signature", signature)
}
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, req)
return rec
}
func TestHook(t *testing.T) {
f := newFixture(t)
body := `{"action":"opened","number":7,"repository":{"full_name":"konairius/homelab"}}`
good := sign("hook-secret", []byte(body))
cases := []struct {
name, event, sig, body string
want int
}{
{"valid", "pull_request", good, body, 202},
{"missing signature", "pull_request", "", body, 401},
{"wrong signature", "pull_request", sign("other", []byte(body)), body, 401},
{"signature that is not hex", "pull_request", "zz", body, 401},
{"other event", "push", good, body, 200},
{"closed action", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "opened", "closed", 1))),
strings.Replace(body, "opened", "closed", 1), 200},
{"synchronized", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "opened", "synchronized", 1))),
strings.Replace(body, "opened", "synchronized", 1), 202},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "konairius/homelab", "x/y", 1))),
strings.Replace(body, "konairius/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`, 400},
{"payload that is not json", "pull_request", sign("hook-secret", []byte("nope")), "nope", 400},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := f.hook(c.event, c.sig, c.body).Code; got != c.want {
t.Errorf("status %d, want %d", got, c.want)
}
})
}
}
func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("first enqueue: %s", r)
}
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("second enqueue of the same PR: %s", r)
}
q.Start(context.Background(), 1)
q.Stop(5 * time.Second)
if n := len(f.postedReviews()); n != 1 {
t.Errorf("posted %d reviews, want 1", n)
}
if r := q.Enqueue("konairius/homelab", 8, ""); r != "full" {
t.Errorf("a stopped queue takes nothing, got %s", r)
}
}
func TestSweepQueuesOnlyWhatNeedsAReview(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
f.rev.Sweep(context.Background(), q)
if len(q.jobs) != 1 {
t.Fatalf("queued %d, want 1", len(q.jobs))
}
f.mu.Lock()
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","user":{"login":"review-bot"}}]`
f.mu.Unlock()
q2 := newQueue(f.rev, 4, f.rev.log, f.metrics)
f.rev.Sweep(context.Background(), q2)
if len(q2.jobs) != 0 {
t.Errorf("queued %d for an already reviewed PR, want 0", 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}
get := func(path string) int {
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
return rec.Code
}
if get("/healthz") != 200 || get("/readyz") != 200 || get("/metrics") != 200 {
t.Error("healthy service must answer 200 on healthz, readyz and metrics")
}
f.rev.setReady("mistral", false)
if get("/healthz") != 200 || get("/readyz") != 503 {
t.Error("without an agent the service is alive but not ready")
}
}
func TestFirstRetryWaitsTheConfiguredTime(t *testing.T) {
f := newFixture(t)
f.rev.cfg.RateLimitWait = 300 * time.Millisecond
f.convStatus = []int{429}
start := time.Now()
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if got := time.Since(start); got < 250*time.Millisecond {
t.Errorf("the first retry came after %v, want at least the configured 300ms", got)
}
}
func TestLimitDelay(t *testing.T) {
limited := func(after time.Duration, has bool) error {
return &apiError{Status: http.StatusTooManyRequests, RetryAfter: after, HasRetryAfter: has}
}
cases := []struct {
name string
retry int
err error
want time.Duration
}{
{"guess, first retry", 1, limited(0, false), time.Minute},
{"guess grows", 3, limited(0, false), 3 * time.Minute},
{"guess is capped", 9, limited(0, false), 5 * time.Minute},
{"Retry-After wins", 1, limited(7*time.Second, true), 7 * time.Second},
{"Retry-After of zero is honoured", 2, limited(0, true), 0},
{"Retry-After is capped", 1, limited(time.Hour, true), 5 * time.Minute},
}
for _, c := range cases {
if got := limitDelay(time.Minute, c.retry, c.err); got != c.want {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[
{"type":"tool.execution","name":"conn_get_file_contents","info":{"result":"var rateLimitText = rate limit handling"}},
{"type":"message.output","content":"## Summary\n\nreviewed\n\n## Recommendation\n\nApprove."}]}`
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || len(f.postedReviews()) != 1 {
t.Errorf("conversations %d, reviews %d; want 1 and 1", f.convCalls, len(f.postedReviews()))
}
}
// A review whose head commit was replaced during the conversation is not posted; the PR is
// reviewed again at the new head instead.
func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
if f.convCalls != 2 || len(posted) != 1 {
t.Fatalf("conversations %d, reviews %d; want 2 and 1", f.convCalls, len(posted))
}
if posted[0]["commit_id"] != "def456" || !strings.Contains(posted[0]["body"], "pr-review:sha=def456") {
t.Errorf("the review is for %q, want def456", posted[0]["commit_id"])
}
}
// A request that arrives while the review runs may be for a newer head, so it is not lost.
func TestQueueReviewsAgainWhenAskedForDuringTheReview(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
q.mu.Lock()
q.running["konairius/homelab#7"] = true // as if a worker had picked it up
q.mu.Unlock()
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("enqueue while running: %s", r)
}
q.mu.Lock()
again := q.again["konairius/homelab#7"]
delete(q.running, "konairius/homelab#7")
q.mu.Unlock()
if !again {
t.Error("the request during the review was forgotten")
}
}
func TestReviewAsksAgainAfterAGatewayFailure(t *testing.T) {
for _, status := range []int{502, 503, 504} {
f := newFixture(t)
f.convStatus = []int{status}
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatalf("%d: %v", status, err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
t.Errorf("%d: conversations %d, reviews %d; want 2 and 1", status, f.convCalls, len(f.postedReviews()))
}
}
}