ac6d34f5bd
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 56s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m51s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m21s
- Introduce vcs.VCSProvider typed constant (replaces plain string provider) - Introduce vcs.ReviewSuperseder optional interface for supersede logic - Implement SupersedeReviews on gitea.Adapter (edit + resolve) and github.Client (dismiss) - Remove concrete type assertion client.(*gitea.Adapter) from main - Remove redundant baseURL fallback for github (NewClient defaults it) - Condense --gitea-url alias comment block - Fix fetchPatterns comment (empty paths are skipped, not fetched) - Add default panic to VCS client init switch Addresses: #19607, #19608, #19609, #19610, #19621, #19622, #19623
894 lines
25 KiB
Go
894 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"flag"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
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 TestValidateWorkspacePath(t *testing.T) {
|
|
// Create a temp directory as our workspace
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a valid file inside the workspace
|
|
validFile := filepath.Join(tmpDir, "valid.json")
|
|
if err := os.WriteFile(validFile, []byte("{}"), 0644); err != nil {
|
|
t.Fatalf("failed to create test file: %v", err)
|
|
}
|
|
|
|
// Create a subdirectory with a file
|
|
subDir := filepath.Join(tmpDir, "subdir")
|
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
|
t.Fatalf("failed to create subdir: %v", err)
|
|
}
|
|
nestedFile := filepath.Join(subDir, "nested.json")
|
|
if err := os.WriteFile(nestedFile, []byte("{}"), 0644); err != nil {
|
|
t.Fatalf("failed to create nested file: %v", err)
|
|
}
|
|
|
|
// Create a symlink pointing outside the workspace
|
|
symlinkPath := filepath.Join(tmpDir, "evil-symlink.json")
|
|
if err := os.Symlink("/etc/passwd", symlinkPath); err != nil {
|
|
t.Fatalf("failed to create symlink: %v", err)
|
|
}
|
|
|
|
// Save and restore GITHUB_WORKSPACE
|
|
origWorkspace := os.Getenv("GITHUB_WORKSPACE")
|
|
defer os.Setenv("GITHUB_WORKSPACE", origWorkspace)
|
|
|
|
tests := []struct {
|
|
name string
|
|
workspace string
|
|
path string
|
|
wantErr bool
|
|
errMatch string
|
|
}{
|
|
{
|
|
name: "valid relative path",
|
|
workspace: tmpDir,
|
|
path: "valid.json",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid nested path",
|
|
workspace: tmpDir,
|
|
path: "subdir/nested.json",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "path traversal attempt",
|
|
workspace: tmpDir,
|
|
path: "../../../etc/passwd",
|
|
wantErr: true,
|
|
errMatch: "resolves outside workspace",
|
|
},
|
|
{
|
|
name: "absolute path normalized to workspace-relative",
|
|
workspace: tmpDir,
|
|
path: "/etc/passwd",
|
|
wantErr: true,
|
|
errMatch: "failed to resolve",
|
|
},
|
|
{
|
|
name: "nonexistent file",
|
|
workspace: tmpDir,
|
|
path: "nonexistent.json",
|
|
wantErr: true,
|
|
errMatch: "failed to resolve",
|
|
},
|
|
{
|
|
name: "symlink escaping workspace",
|
|
workspace: tmpDir,
|
|
path: "evil-symlink.json",
|
|
wantErr: true,
|
|
errMatch: "symlink resolves outside workspace",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
os.Setenv("GITHUB_WORKSPACE", tc.workspace)
|
|
resolved, err := validateWorkspacePath(tc.path, "test-file")
|
|
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expected error for %q, got nil", tc.path)
|
|
} else if tc.errMatch != "" && !strings.Contains(err.Error(), tc.errMatch) {
|
|
t.Errorf("error %q should contain %q", err.Error(), tc.errMatch)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("expected no error for %q, got %v", tc.path, err)
|
|
}
|
|
if resolved == "" {
|
|
t.Error("expected non-empty resolved path")
|
|
}
|
|
// Verify resolved path is within workspace
|
|
if !strings.HasPrefix(resolved, tc.workspace) {
|
|
t.Errorf("resolved path %q not within workspace %q", resolved, tc.workspace)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeReview(id int64, login, state string, stale bool, body string) vcs.Review {
|
|
return vcs.Review{
|
|
ID: id,
|
|
Body: body,
|
|
User: vcs.UserInfo{Login: login},
|
|
State: state,
|
|
Stale: stale,
|
|
}
|
|
}
|
|
|
|
func TestHasSharedToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reviews []vcs.Review
|
|
sentinel string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no reviews",
|
|
reviews: nil,
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "no own review yet - cannot detect",
|
|
reviews: []vcs.Review{
|
|
makeReview(1, "other", "APPROVED", false, "<!-- review-bot:gpt --> body"),
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "separate users - no shared token",
|
|
reviews: []vcs.Review{
|
|
makeReview(1, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"),
|
|
makeReview(2, "security-review-bot", "APPROVED", false, "<!-- review-bot:security --> body"),
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "shared token detected - same user different sentinels",
|
|
reviews: []vcs.Review{
|
|
makeReview(1, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"),
|
|
makeReview(2, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:security --> body"),
|
|
},
|
|
sentinel: "<!-- review-bot:sonnet -->",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "three roles same user",
|
|
reviews: []vcs.Review{
|
|
makeReview(1, "bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"),
|
|
makeReview(2, "bot", "APPROVED", false, "<!-- review-bot:security --> body"),
|
|
makeReview(3, "bot", "APPROVED", false, "<!-- 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 []vcs.CommitStatus
|
|
wantPassed bool
|
|
wantSubstr string
|
|
}{
|
|
{
|
|
name: "empty statuses",
|
|
statuses: nil,
|
|
wantPassed: true,
|
|
wantSubstr: "no CI statuses",
|
|
},
|
|
{
|
|
name: "all success",
|
|
statuses: []vcs.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: []vcs.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: []vcs.CommitStatus{
|
|
{Status: "error", Context: "ci/lint", Description: "Lint error"},
|
|
},
|
|
wantPassed: false,
|
|
wantSubstr: "ci/lint",
|
|
},
|
|
{
|
|
name: "pending treated as not-failed",
|
|
statuses: []vcs.CommitStatus{
|
|
{Status: "pending", Context: "ci/build", Description: "In progress"},
|
|
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
|
},
|
|
wantPassed: true,
|
|
wantSubstr: "no failures",
|
|
},
|
|
{
|
|
name: "multiple failures",
|
|
statuses: []vcs.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: []vcs.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 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",
|
|
"--vcs-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",
|
|
"--vcs-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",
|
|
"--vcs-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",
|
|
"--vcs-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",
|
|
"--vcs-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)
|
|
}
|
|
}
|
|
|
|
func TestMainSubprocess_InvalidVCSProvider(t *testing.T) {
|
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
os.Args = []string{"review-bot",
|
|
"--provider", "invalid",
|
|
"--vcs-url", "http://localhost",
|
|
"--repo", "owner/repo",
|
|
"--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_InvalidVCSProvider")
|
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected non-zero exit with invalid VCS provider")
|
|
}
|
|
if !strings.Contains(string(out), "invalid --provider") {
|
|
t.Errorf("expected error about invalid --provider, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// cleanEnv returns environ without any GITEA/LLM/REVIEWER/VCS 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, "VCS_"),
|
|
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
|
|
}
|
|
|
|
func TestFindAllOwnReviews(t *testing.T) {
|
|
reviews := []vcs.Review{
|
|
makeReview(1, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nfirst review"),
|
|
makeReview(2, "bot", "APPROVED", false, "<!-- review-bot:gpt -->\nother bot"),
|
|
makeReview(3, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nsecond review"),
|
|
makeReview(4, "bot", "APPROVED", false, "~~Original review~~\n<!-- review-bot:sonnet -->\nsuperseded"),
|
|
makeReview(5, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nthird review"),
|
|
}
|
|
|
|
got := findAllOwnReviews(reviews, "<!-- review-bot:sonnet -->")
|
|
if len(got) != 3 {
|
|
t.Fatalf("findAllOwnReviews() returned %d, want 3", len(got))
|
|
}
|
|
wantIDs := []int64{1, 3, 5}
|
|
for i, r := range got {
|
|
if r.ID != wantIDs[i] {
|
|
t.Errorf("got[%d].ID = %d, want %d", i, r.ID, wantIDs[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShouldSkipStaleReview(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
evaluatedSHA string
|
|
currentSHA string
|
|
wantSkip bool
|
|
}{
|
|
{
|
|
name: "matching SHAs",
|
|
evaluatedSHA: "abc123def456",
|
|
currentSHA: "abc123def456",
|
|
wantSkip: false,
|
|
},
|
|
{
|
|
name: "different SHAs",
|
|
evaluatedSHA: "abc123def456",
|
|
currentSHA: "xyz789abc123",
|
|
wantSkip: true,
|
|
},
|
|
{
|
|
name: "empty current SHA (re-fetch failed)",
|
|
evaluatedSHA: "abc123def456",
|
|
currentSHA: "",
|
|
wantSkip: false,
|
|
},
|
|
{
|
|
name: "both empty (edge case)",
|
|
evaluatedSHA: "",
|
|
currentSHA: "",
|
|
wantSkip: false,
|
|
},
|
|
{
|
|
name: "only current empty",
|
|
evaluatedSHA: "abc123",
|
|
currentSHA: "",
|
|
wantSkip: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := shouldSkipStaleReview(tc.evaluatedSHA, tc.currentSHA)
|
|
if got != tc.wantSkip {
|
|
t.Errorf("shouldSkipStaleReview(%q, %q) = %v, want %v",
|
|
tc.evaluatedSHA, tc.currentSHA, got, tc.wantSkip)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerdictToEvent(t *testing.T) {
|
|
tests := []struct {
|
|
verdict string
|
|
want vcs.ReviewEvent
|
|
}{
|
|
{"APPROVE", vcs.ReviewEventApprove},
|
|
{"REQUEST_CHANGES", vcs.ReviewEventRequestChanges},
|
|
{"COMMENT", vcs.ReviewEventComment},
|
|
{"other", vcs.ReviewEventComment},
|
|
{"", vcs.ReviewEventComment},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := verdictToEvent(tc.verdict)
|
|
if got != tc.want {
|
|
t.Errorf("verdictToEvent(%q) = %q, want %q", tc.verdict, got, tc.want)
|
|
}
|
|
}
|
|
}
|