bb596db3c1
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 22s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m6s
Partially addresses #32 - Tests for setupLogger, isPatternFile, evaluateCIStatus, envOrDefault*, validateReviewerName - Subprocess tests for main() error paths (version, missing flags, invalid inputs) - Integration test scaffold (build tag: integration) - Makefile with build/test/lint/coverage targets - Coverage: 16.7% → 42.3% for cmd/review-bot
848 lines
24 KiB
Go
848 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"flag"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
|
)
|
|
|
|
func TestValidateReviewerName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr bool
|
|
}{
|
|
{"valid simple", "sonnet", false},
|
|
{"valid with dash", "code-review", false},
|
|
{"valid with underscore", "my_bot", false},
|
|
{"valid alphanumeric", "bot123", false},
|
|
{"valid uppercase", "MyBot", false},
|
|
{"empty is valid", "", false},
|
|
{"invalid html close", "foo-->", true},
|
|
{"invalid space", "my bot", true},
|
|
{"invalid dot", "my.bot", true},
|
|
{"invalid slash", "my/bot", true},
|
|
{"invalid angle", "bot<script>", true},
|
|
{"invalid colon", "bot:name", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := validateReviewerName(tc.input)
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("expected error for %q, got nil", tc.input)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("expected no error for %q, got %v", tc.input, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
|
|
r := gitea.Review{
|
|
ID: id,
|
|
Body: body,
|
|
State: state,
|
|
Stale: stale,
|
|
}
|
|
r.User.Login = login
|
|
return r
|
|
}
|
|
|
|
func TestReviewUnchanged(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
existing []gitea.Review
|
|
newBody string
|
|
newEvent string
|
|
sentinel string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no existing review",
|
|
existing: nil,
|
|
newBody: "new review",
|
|
newEvent: "APPROVED",
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "identical body and state",
|
|
existing: []gitea.Review{
|
|
makeReview(100, "bot", "APPROVED", false, "same body\n<!-- review-bot:sonnet -->"),
|
|
},
|
|
newBody: "same body\n<!-- review-bot:sonnet -->",
|
|
newEvent: "APPROVED",
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "same body but different state",
|
|
existing: []gitea.Review{
|
|
makeReview(100, "bot", "APPROVED", false, "body\n<!-- review-bot:sonnet -->"),
|
|
},
|
|
newBody: "body\n<!-- review-bot:sonnet -->",
|
|
newEvent: "REQUEST_CHANGES",
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "different body same state",
|
|
existing: []gitea.Review{
|
|
makeReview(100, "bot", "APPROVED", false, "old body\n<!-- review-bot:sonnet -->"),
|
|
},
|
|
newBody: "new body\n<!-- review-bot:sonnet -->",
|
|
newEvent: "APPROVED",
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "stale review with same body (should still post)",
|
|
existing: []gitea.Review{
|
|
makeReview(100, "bot", "APPROVED", true, "same\n<!-- review-bot:sonnet -->"),
|
|
},
|
|
newBody: "same\n<!-- review-bot:sonnet -->",
|
|
newEvent: "APPROVED",
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "different sentinel (not our review)",
|
|
existing: []gitea.Review{
|
|
makeReview(100, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
|
|
},
|
|
newBody: "body\n<!-- review-bot:sonnet -->",
|
|
newEvent: "APPROVED",
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := reviewUnchanged(tc.existing, tc.newBody, tc.newEvent, tc.sentinel)
|
|
if got != tc.want {
|
|
t.Errorf("reviewUnchanged() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindOwnReview(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reviews []gitea.Review
|
|
sentinel string
|
|
wantID int64
|
|
wantNil bool
|
|
}{
|
|
{
|
|
name: "no reviews",
|
|
reviews: nil,
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "found by sentinel",
|
|
reviews: []gitea.Review{
|
|
makeReview(42, "bot", "APPROVED", false, "review body\n<!-- review-bot:sonnet -->"),
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
wantID: 42,
|
|
},
|
|
{
|
|
name: "wrong sentinel",
|
|
reviews: []gitea.Review{
|
|
makeReview(42, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "multiple reviews, returns first match",
|
|
reviews: []gitea.Review{
|
|
makeReview(10, "bot", "APPROVED", false, "old\n<!-- review-bot:gpt -->"),
|
|
makeReview(20, "bot", "APPROVED", false, "new\n<!-- review-bot:sonnet -->"),
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
wantID: 20,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := findOwnReview(tc.reviews, tc.sentinel)
|
|
if tc.wantNil {
|
|
if got != nil {
|
|
t.Errorf("findOwnReview() = %v, want nil", got)
|
|
}
|
|
} else {
|
|
if got == nil {
|
|
t.Fatal("findOwnReview() = nil, want non-nil")
|
|
}
|
|
if got.ID != tc.wantID {
|
|
t.Errorf("findOwnReview().ID = %d, want %d", got.ID, tc.wantID)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasSharedToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reviews []gitea.Review
|
|
sentinel string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no reviews",
|
|
reviews: nil,
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "no own review yet - cannot detect",
|
|
reviews: []gitea.Review{
|
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "other"}, Body: "<!-- review-bot:gpt --> body"},
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "separate users - no shared token",
|
|
reviews: []gitea.Review{
|
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
|
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "security-review-bot"}, Body: "<!-- review-bot:security --> body"},
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "shared token detected - same user different sentinels",
|
|
reviews: []gitea.Review{
|
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
|
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:security --> body"},
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "three roles same user",
|
|
reviews: []gitea.Review{
|
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
|
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:security --> body"},
|
|
{ID: 3, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"},
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hasSharedToken(tc.reviews, tc.sentinel)
|
|
if got != tc.want {
|
|
t.Errorf("hasSharedToken() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractSentinelName(t *testing.T) {
|
|
tests := []struct {
|
|
body string
|
|
want string
|
|
}{
|
|
{"<!-- review-bot:sonnet --> rest", "sonnet"},
|
|
{"<!-- review-bot:security --> rest", "security"},
|
|
{"no sentinel here", "unknown"},
|
|
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := extractSentinelName(tc.body)
|
|
if got != tc.want {
|
|
t.Errorf("extractSentinelName(%q) = %q, want %q", tc.body, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetupLogger_JSONFormat(t *testing.T) {
|
|
// Capture output by creating a logger manually with the same logic
|
|
var buf bytes.Buffer
|
|
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
|
|
handler := slog.NewJSONHandler(&buf, opts)
|
|
logger := slog.New(handler)
|
|
|
|
logger.Info("test message", "key", "value")
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, `"msg":"test message"`) {
|
|
t.Errorf("expected JSON msg field, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, `"key":"value"`) {
|
|
t.Errorf("expected JSON key field, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, `"level":"INFO"`) {
|
|
t.Errorf("expected JSON level field, got: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestSetupLogger_TextFormat(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
|
|
handler := slog.NewTextHandler(&buf, opts)
|
|
logger := slog.New(handler)
|
|
|
|
logger.Info("test message", "key", "value")
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, "msg=\"test message\"") && !strings.Contains(output, "msg=test") {
|
|
t.Errorf("expected text msg field, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, "key=value") {
|
|
t.Errorf("expected text key field, got: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestSetupLogger_LevelFiltering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
verbosity string
|
|
logLevel slog.Level
|
|
expected bool // should the message appear
|
|
}{
|
|
{"info logger shows info", "info", slog.LevelInfo, true},
|
|
{"info logger hides debug", "info", slog.LevelDebug, false},
|
|
{"debug logger shows debug", "debug", slog.LevelDebug, true},
|
|
{"warn logger hides info", "warn", slog.LevelInfo, false},
|
|
{"warn logger shows warn", "warn", slog.LevelWarn, true},
|
|
{"error logger hides warn", "error", slog.LevelWarn, false},
|
|
{"error logger shows error", "error", slog.LevelError, true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var level slog.Level
|
|
switch tc.verbosity {
|
|
case "debug":
|
|
level = slog.LevelDebug
|
|
case "info":
|
|
level = slog.LevelInfo
|
|
case "warn":
|
|
level = slog.LevelWarn
|
|
case "error":
|
|
level = slog.LevelError
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
opts := &slog.HandlerOptions{Level: level}
|
|
handler := slog.NewTextHandler(&buf, opts)
|
|
logger := slog.New(handler)
|
|
|
|
logger.Log(nil, tc.logLevel, "test")
|
|
|
|
hasOutput := buf.Len() > 0
|
|
if hasOutput != tc.expected {
|
|
t.Errorf("verbosity=%s, logLevel=%s: got output=%v, want %v",
|
|
tc.verbosity, tc.logLevel, hasOutput, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetupLogger_Integration(t *testing.T) {
|
|
// Test that setupLogger doesn't panic for valid inputs
|
|
setupLogger("text", "info")
|
|
setupLogger("json", "debug")
|
|
setupLogger("text", "warn")
|
|
setupLogger("json", "error")
|
|
setupLogger("text", "unknown") // should default to info
|
|
setupLogger("invalid", "info") // should default to text
|
|
}
|
|
|
|
func TestIsPatternFile(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{"README.md", true},
|
|
{"docs/GUIDE.MD", true},
|
|
{"config.yml", true},
|
|
{"config.yaml", true},
|
|
{"notes.txt", true},
|
|
{"NOTES.TXT", true},
|
|
{"main.go", false},
|
|
{"lib.rs", false},
|
|
{"index.js", false},
|
|
{"Makefile", false},
|
|
{"", false},
|
|
{"doc.pdf", false},
|
|
{"patterns.Yml", true},
|
|
{"deep/path/file.yaml", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
got := isPatternFile(tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("isPatternFile(%q) = %v, want %v", tc.path, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCIStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
statuses []gitea.CommitStatus
|
|
wantPassed bool
|
|
wantSubstr string
|
|
}{
|
|
{
|
|
name: "empty statuses",
|
|
statuses: nil,
|
|
wantPassed: true,
|
|
wantSubstr: "no CI statuses",
|
|
},
|
|
{
|
|
name: "all success",
|
|
statuses: []gitea.CommitStatus{
|
|
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
|
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
|
},
|
|
wantPassed: true,
|
|
wantSubstr: "all checks passed",
|
|
},
|
|
{
|
|
name: "one failure",
|
|
statuses: []gitea.CommitStatus{
|
|
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
|
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
|
},
|
|
wantPassed: false,
|
|
wantSubstr: "ci/test",
|
|
},
|
|
{
|
|
name: "error status",
|
|
statuses: []gitea.CommitStatus{
|
|
{Status: "error", Context: "ci/lint", Description: "Lint error"},
|
|
},
|
|
wantPassed: false,
|
|
wantSubstr: "ci/lint",
|
|
},
|
|
{
|
|
name: "pending treated as not-failed",
|
|
statuses: []gitea.CommitStatus{
|
|
{Status: "pending", Context: "ci/build", Description: "In progress"},
|
|
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
|
},
|
|
wantPassed: true,
|
|
wantSubstr: "all checks passed",
|
|
},
|
|
{
|
|
name: "multiple failures",
|
|
statuses: []gitea.CommitStatus{
|
|
{Status: "failure", Context: "ci/build", Description: "Build failed"},
|
|
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
|
},
|
|
wantPassed: false,
|
|
wantSubstr: "ci/build",
|
|
},
|
|
{
|
|
name: "mixed with pending and failure",
|
|
statuses: []gitea.CommitStatus{
|
|
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
|
{Status: "pending", Context: "ci/deploy", Description: "Deploying"},
|
|
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
|
},
|
|
wantPassed: false,
|
|
wantSubstr: "ci/test",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
passed, details := evaluateCIStatus(tc.statuses)
|
|
if passed != tc.wantPassed {
|
|
t.Errorf("evaluateCIStatus() passed = %v, want %v", passed, tc.wantPassed)
|
|
}
|
|
if !strings.Contains(details, tc.wantSubstr) {
|
|
t.Errorf("evaluateCIStatus() details = %q, want substring %q", details, tc.wantSubstr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnvOrDefault(t *testing.T) {
|
|
// Test with unset env var
|
|
os.Unsetenv("TEST_ENV_OR_DEFAULT_UNSET")
|
|
got := envOrDefault("TEST_ENV_OR_DEFAULT_UNSET", "fallback")
|
|
if got != "fallback" {
|
|
t.Errorf("envOrDefault(unset) = %q, want %q", got, "fallback")
|
|
}
|
|
|
|
// Test with set env var
|
|
os.Setenv("TEST_ENV_OR_DEFAULT_SET", "custom")
|
|
defer os.Unsetenv("TEST_ENV_OR_DEFAULT_SET")
|
|
got = envOrDefault("TEST_ENV_OR_DEFAULT_SET", "fallback")
|
|
if got != "custom" {
|
|
t.Errorf("envOrDefault(set) = %q, want %q", got, "custom")
|
|
}
|
|
|
|
// Test with empty env var (should return default)
|
|
os.Setenv("TEST_ENV_OR_DEFAULT_EMPTY", "")
|
|
defer os.Unsetenv("TEST_ENV_OR_DEFAULT_EMPTY")
|
|
got = envOrDefault("TEST_ENV_OR_DEFAULT_EMPTY", "fallback")
|
|
if got != "fallback" {
|
|
t.Errorf("envOrDefault(empty) = %q, want %q", got, "fallback")
|
|
}
|
|
}
|
|
|
|
func TestEnvOrDefaultFloat(t *testing.T) {
|
|
// Test with unset env var
|
|
os.Unsetenv("TEST_ENV_FLOAT_UNSET")
|
|
got := envOrDefaultFloat("TEST_ENV_FLOAT_UNSET", 1.5)
|
|
if got != 1.5 {
|
|
t.Errorf("envOrDefaultFloat(unset) = %f, want %f", got, 1.5)
|
|
}
|
|
|
|
// Test with valid float
|
|
os.Setenv("TEST_ENV_FLOAT_SET", "2.7")
|
|
defer os.Unsetenv("TEST_ENV_FLOAT_SET")
|
|
got = envOrDefaultFloat("TEST_ENV_FLOAT_SET", 1.5)
|
|
if got != 2.7 {
|
|
t.Errorf("envOrDefaultFloat(set) = %f, want %f", got, 2.7)
|
|
}
|
|
|
|
// Test with invalid float (should return default)
|
|
os.Setenv("TEST_ENV_FLOAT_INVALID", "not-a-number")
|
|
defer os.Unsetenv("TEST_ENV_FLOAT_INVALID")
|
|
got = envOrDefaultFloat("TEST_ENV_FLOAT_INVALID", 3.14)
|
|
if got != 3.14 {
|
|
t.Errorf("envOrDefaultFloat(invalid) = %f, want %f", got, 3.14)
|
|
}
|
|
|
|
// Test with empty string (should return default)
|
|
os.Setenv("TEST_ENV_FLOAT_EMPTY", "")
|
|
defer os.Unsetenv("TEST_ENV_FLOAT_EMPTY")
|
|
got = envOrDefaultFloat("TEST_ENV_FLOAT_EMPTY", 0.5)
|
|
if got != 0.5 {
|
|
t.Errorf("envOrDefaultFloat(empty) = %f, want %f", got, 0.5)
|
|
}
|
|
}
|
|
|
|
func TestEnvOrDefaultInt(t *testing.T) {
|
|
// Test with unset env var
|
|
os.Unsetenv("TEST_ENV_INT_UNSET")
|
|
got := envOrDefaultInt("TEST_ENV_INT_UNSET", 42)
|
|
if got != 42 {
|
|
t.Errorf("envOrDefaultInt(unset) = %d, want %d", got, 42)
|
|
}
|
|
|
|
// Test with valid int
|
|
os.Setenv("TEST_ENV_INT_SET", "100")
|
|
defer os.Unsetenv("TEST_ENV_INT_SET")
|
|
got = envOrDefaultInt("TEST_ENV_INT_SET", 42)
|
|
if got != 100 {
|
|
t.Errorf("envOrDefaultInt(set) = %d, want %d", got, 100)
|
|
}
|
|
|
|
// Test with invalid int (should return default)
|
|
os.Setenv("TEST_ENV_INT_INVALID", "abc")
|
|
defer os.Unsetenv("TEST_ENV_INT_INVALID")
|
|
got = envOrDefaultInt("TEST_ENV_INT_INVALID", 42)
|
|
if got != 42 {
|
|
t.Errorf("envOrDefaultInt(invalid) = %d, want %d", got, 42)
|
|
}
|
|
|
|
// Test with empty string (should return default)
|
|
os.Setenv("TEST_ENV_INT_EMPTY", "")
|
|
defer os.Unsetenv("TEST_ENV_INT_EMPTY")
|
|
got = envOrDefaultInt("TEST_ENV_INT_EMPTY", 99)
|
|
if got != 99 {
|
|
t.Errorf("envOrDefaultInt(empty) = %d, want %d", got, 99)
|
|
}
|
|
|
|
// Test with negative int
|
|
os.Setenv("TEST_ENV_INT_NEG", "-5")
|
|
defer os.Unsetenv("TEST_ENV_INT_NEG")
|
|
got = envOrDefaultInt("TEST_ENV_INT_NEG", 42)
|
|
if got != -5 {
|
|
t.Errorf("envOrDefaultInt(negative) = %d, want %d", got, -5)
|
|
}
|
|
}
|
|
|
|
func TestEnvOrDefaultBool(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envVal string
|
|
setEnv bool
|
|
defaultVal bool
|
|
want bool
|
|
}{
|
|
{"unset returns default true", "", false, true, true},
|
|
{"unset returns default false", "", false, false, false},
|
|
{"true", "true", true, false, true},
|
|
{"TRUE", "TRUE", true, false, true},
|
|
{"True", "True", true, false, true},
|
|
{"1", "1", true, false, true},
|
|
{"yes", "yes", true, false, true},
|
|
{"YES", "YES", true, false, true},
|
|
{"false", "false", true, true, false},
|
|
{"0", "0", true, true, false},
|
|
{"no", "no", true, true, false},
|
|
{"random string", "random", true, true, false},
|
|
{"empty string returns default", "", true, true, true},
|
|
{"whitespace true", " true ", true, false, true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
envKey := "TEST_ENV_BOOL_" + strings.ReplaceAll(tc.name, " ", "_")
|
|
if tc.setEnv {
|
|
os.Setenv(envKey, tc.envVal)
|
|
defer os.Unsetenv(envKey)
|
|
} else {
|
|
os.Unsetenv(envKey)
|
|
}
|
|
got := envOrDefaultBool(envKey, tc.defaultVal)
|
|
if got != tc.want {
|
|
t.Errorf("envOrDefaultBool(%q, %v) = %v, want %v", tc.envVal, tc.defaultVal, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractSentinelName_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
body string
|
|
want string
|
|
}{
|
|
{"<!-- review-bot:sonnet --> rest", "sonnet"},
|
|
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
|
|
{"no sentinel here", "unknown"},
|
|
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
|
|
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := extractSentinelName(tc.body)
|
|
if got != tc.want {
|
|
t.Errorf("extractSentinelName(%q) = %q, want %q", tc.body, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMainSubprocess runs main() as a subprocess using the test binary itself.
|
|
// This allows coverage to be captured for main() code paths.
|
|
func TestMainSubprocess_Version(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
// Reset flags for main()
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot", "--version"}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_Version")
|
|
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("--version subprocess failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(string(out), "review-bot") {
|
|
t.Errorf("--version output = %q, want to contain 'review-bot'", string(out))
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_MissingFlags(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
// Reset flags for main()
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot"}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_MissingFlags")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with no flags, got success")
|
|
}
|
|
if !strings.Contains(string(out), "missing required") {
|
|
t.Errorf("expected error about missing flags, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot",
|
|
"--gitea-url", "http://localhost",
|
|
"--repo", "owner/repo",
|
|
"--pr", "1",
|
|
"--reviewer-name", "invalid name",
|
|
"--reviewer-token", "tok",
|
|
"--llm-base-url", "http://localhost",
|
|
"--llm-api-key", "key",
|
|
"--llm-model", "model",
|
|
}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidReviewerName")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with invalid reviewer-name, got success")
|
|
}
|
|
if !strings.Contains(string(out), "invalid reviewer name") {
|
|
t.Errorf("expected error about invalid reviewer name, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot",
|
|
"--gitea-url", "http://localhost",
|
|
"--repo", "invalidrepo",
|
|
"--pr", "1",
|
|
"--reviewer-token", "tok",
|
|
"--llm-base-url", "http://localhost",
|
|
"--llm-api-key", "key",
|
|
"--llm-model", "model",
|
|
}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidRepo")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with invalid repo format")
|
|
}
|
|
if !strings.Contains(string(out), "invalid repo format") {
|
|
t.Errorf("expected error about invalid repo, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot",
|
|
"--gitea-url", "http://localhost",
|
|
"--repo", "owner/repo",
|
|
"--pr", "notanumber",
|
|
"--reviewer-token", "tok",
|
|
"--llm-base-url", "http://localhost",
|
|
"--llm-api-key", "key",
|
|
"--llm-model", "model",
|
|
}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidPRNumber")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with invalid PR number")
|
|
}
|
|
if !strings.Contains(string(out), "invalid PR number") {
|
|
t.Errorf("expected error about invalid PR number, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot",
|
|
"--gitea-url", "http://localhost",
|
|
"--repo", "owner/repo",
|
|
"--pr", "1",
|
|
"--reviewer-token", "tok",
|
|
"--llm-base-url", "http://localhost",
|
|
"--llm-api-key", "key",
|
|
"--llm-model", "model",
|
|
"--llm-temperature", "5.0",
|
|
}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidTemperature")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with invalid temperature")
|
|
}
|
|
if !strings.Contains(string(out), "invalid LLM temperature") {
|
|
t.Errorf("expected error about invalid temperature, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot",
|
|
"--gitea-url", "http://localhost",
|
|
"--repo", "owner/repo",
|
|
"--pr", "1",
|
|
"--reviewer-token", "tok",
|
|
"--llm-base-url", "http://localhost",
|
|
"--llm-api-key", "key",
|
|
"--llm-model", "model",
|
|
"--llm-provider", "invalid-provider",
|
|
}
|
|
main()
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidProvider")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with invalid provider")
|
|
}
|
|
if !strings.Contains(string(out), "invalid LLM provider") {
|
|
t.Errorf("expected error about invalid provider, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// cleanEnv returns environ without any GITEA/LLM/REVIEWER env vars that would
|
|
// interfere with testing missing-flag scenarios.
|
|
func cleanEnv() []string {
|
|
var env []string
|
|
for _, e := range os.Environ() {
|
|
key := strings.SplitN(e, "=", 2)[0]
|
|
switch {
|
|
case strings.HasPrefix(key, "GITEA_"),
|
|
strings.HasPrefix(key, "LLM_"),
|
|
strings.HasPrefix(key, "REVIEWER_"),
|
|
strings.HasPrefix(key, "PR_"),
|
|
strings.HasPrefix(key, "LOG_"),
|
|
strings.HasPrefix(key, "CONVENTIONS_"),
|
|
strings.HasPrefix(key, "SYSTEM_PROMPT_"),
|
|
strings.HasPrefix(key, "PATTERNS_"),
|
|
strings.HasPrefix(key, "UPDATE_"):
|
|
continue
|
|
default:
|
|
env = append(env, e)
|
|
}
|
|
}
|
|
return env
|
|
}
|