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>
165 lines
6.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|