Files
claude-botandClaude Opus 5.5 04f1f1d639
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/review-bot-test Pipeline was successful
fix(review-bot): approve with Gitea's APPROVED event
The bot posted approvals with event "APPROVE". Gitea knows APPROVED,
REQUEST_CHANGES and COMMENT and stores any other value as a pending review
that only its author sees (routers/api/v1/repo/pull_review.go,
preparePullReviewType, default branch). Every approval since cdba8b62 is such
a pending draft: homelab #1270, #1278, #1287, #1318, homeassistant #385.

The bot also counted its own pending review as "reviewed at this head", so
those PRs were never reviewed again. A pending review no longer counts; the
sweep reviews them again, and Gitea submits the existing pending review with
the new event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:09:47 +02:00

165 lines
6.6 KiB
Go

package main
import (
"strings"
"testing"
)
func TestCleanReport(t *testing.T) {
cases := []struct{ name, in, want string }{
{"drops the preamble",
"I read the files.\nLet me check.\n\n## Summary\n\nAdds a field.\n\n## Recommendation\n\nLooks reasonable.",
"## Summary\n\nAdds a field.\n\n## Recommendation\n\nLooks reasonable."},
{"drops a section that only says None",
"## Summary\n\nFine.\n\n## 🔴 Blocking\n\nNone.\n\n## Recommendation\n\nOk.",
"## Summary\n\nFine.\n\n## Recommendation\n\nOk."},
{"drops an empty section and keeps a real one",
"## Summary\n\nx\n\n## 🟡 Suggestions\n\n## 🔴 Blocking\n\n1. `a.yaml:3` is wrong.\n\n## Recommendation\n\nRequest changes — 1 blocking issue(s)",
"## Summary\n\nx\n\n## 🔴 Blocking\n\n1. `a.yaml:3` is wrong.\n\n## Recommendation\n\nRequest changes — 1 blocking issue(s)"},
{"keeps a section that merely starts with None",
"## Summary\n\nx\n\n## Tests\n\nNone of the new paths are covered.",
"## Summary\n\nx\n\n## Tests\n\nNone of the new paths are covered."},
{"no heading gives nothing", "just chatter", ""},
{"windows line endings", "## Summary\r\n\r\nx\r\n", "## Summary\n\nx"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := cleanReport(c.in); got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestEligible(t *testing.T) {
pr := func(state, title string, draft bool, sha string) PR {
p := PR{State: state, Title: title, Draft: draft}
p.Head.SHA = sha
return p
}
review := func(login, body string) Review {
r := Review{Body: body}
r.User.Login = login
return r
}
reviews := []Review{
review("review-bot", "x\n<!-- pr-review:sha=aaa111 -->"),
review("someone", "<!-- pr-review:sha=bbb222 -->"),
}
cases := []struct {
name string
pr PR
want string
}{
{"open at a new head", pr("open", "feat: x", false, "ccc333"), "ok"},
{"review at an older head does not count", pr("open", "feat: x", false, "ddd444"), "ok"},
{"review at this head by the bot", pr("open", "feat: x", false, "aaa111"), "reviewed"},
{"marker from another user does not count", pr("open", "feat: x", false, "bbb222"), "ok"},
{"closed", pr("closed", "feat: x", false, "ccc333"), "closed"},
{"draft", pr("open", "feat: x", true, "ccc333"), "draft"},
{"wip with colon", pr("open", "WIP: x", false, "ccc333"), "wip"},
{"wip with space", pr("open", "wip x", false, "ccc333"), "wip"},
{"title that only starts with wip", pr("open", "wipe the cache", false, "ccc333"), "ok"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := eligible(c.pr, reviews, "review-bot"); got != c.want {
t.Errorf("got %s, want %s", got, c.want)
}
})
}
}
func TestParseAnswer(t *testing.T) {
raw := `{"outputs":[
{"type":"tool.execution","name":"conn_0b63_get_file_contents"},
{"type":"tool.execution","name":"conn_0b63_pull_request_read"},
{"type":"tool.execution","name":"conn_0b63_list_commits"},
{"type":"tool.execution","name":"conn_0b63_search_repos"},
{"type":"message.output","content":[{"type":"thinking","thinking":"hmm"},{"type":"text","text":"## Summary\n\nok"}]}]}`
a, err := parseAnswer([]byte(raw))
if err != nil {
t.Fatal(err)
}
if a.ToolCalls != 4 || len(a.Violations) != 0 || a.Text != "## Summary\n\nok" {
t.Errorf("unexpected answer: %+v", a)
}
bad := `{"outputs":[
{"type":"tool.execution","name":"conn_0b63_get_me"},
{"type":"tool.execution","name":"conn_0b63_create_or_update_file"},
{"type":"tool.execution","name":"conn_0b63_pull_request_review_write"},
{"type":"tool.execution","name":"conn_0b63_delete_branch"},
{"type":"message.output","content":"plain text"}]}`
a, err = parseAnswer([]byte(bad))
if err != nil {
t.Fatal(err)
}
want := []string{"conn_0b63_create_or_update_file", "conn_0b63_pull_request_review_write", "conn_0b63_delete_branch"}
if len(a.Violations) != len(want) {
t.Fatalf("violations %v, want %v", a.Violations, want)
}
for i := range want {
if a.Violations[i] != want[i] {
t.Errorf("violation %d: %s, want %s", i, a.Violations[i], want[i])
}
}
if a.Text != "plain text" {
t.Errorf("a string content is the text, got %q", a.Text)
}
if a, err = parseAnswer([]byte(`{"message":"upstream error"}`)); err != nil || a.ToolCalls != 0 || a.Text != "" {
t.Errorf("an answer without outputs is empty, got %+v, %v", a, err)
}
}
func TestSplitVerdict(t *testing.T) {
cases := []struct {
name, in, wantRest, wantVerdict string
}{
{"request changes", "## A\n\nx\n\nVerdict: request-changes\n", "## A\n\nx\n", "request-changes"},
{"spelled with a space, any case", "## A\n\nVERDICT: Request Changes", "## A\n", "request-changes"},
{"approve", "## A\n\nx\nVerdict: approve", "## A\n\nx", "approve"},
{"missing", "## A\n\nx", "## A\n\nx", ""},
{"not the last line", "Verdict: approve\n\n## A\n\nx", "Verdict: approve\n\n## A\n\nx", ""},
{"unknown value", "## A\n\nVerdict: maybe", "## A\n\nVerdict: maybe", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rest, verdict := splitVerdict(c.in)
if strings.TrimSpace(rest) != strings.TrimSpace(c.wantRest) || verdict != c.wantVerdict {
t.Errorf("got %q, %q; want %q, %q", rest, verdict, c.wantRest, c.wantVerdict)
}
})
}
}
func TestReviewEvent(t *testing.T) {
const clean = "## Summary\n\nx\n\n## Recommendation\n\nz"
const yellow = "## Summary\n\nx\n\n## 🟡 Suggestions\n\n- a\n\n## Recommendation\n\nz"
const unverified = "## Summary\n\nx\n\n## ⚪ Unverified\n\n- a\n\n## Recommendation\n\nz"
const red = "## Summary\n\nx\n\n## 🔴 Blocking\n\nNone at this head; an earlier finding is fixed.\n\n## Recommendation\n\nz"
cases := []struct {
name, verdict, report string
nonEmpty bool
want string
}{
{"the agent requests changes", "request-changes", clean, true, "REQUEST_CHANGES"},
{"a red heading alone does not request changes", "comment", red, true, "COMMENT"},
{"approve", "approve", clean, true, "APPROVED"},
{"approve with a yellow section is only a comment", "approve", yellow, true, "COMMENT"},
{"approve with a red section is only a comment", "approve", red, true, "COMMENT"},
{"approve with an unverified section approves", "approve", unverified, true, "APPROVED"},
{"an empty change never approves", "approve", clean, false, "COMMENT"},
{"comment", "comment", yellow, true, "COMMENT"},
{"no verdict is a comment", "", clean, true, "COMMENT"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := reviewEvent(c.verdict, c.report, c.nonEmpty); got != c.want {
t.Errorf("got %s, want %s", got, c.want)
}
})
}
}