9775cb098c
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 9m32s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 9m55s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 10m38s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 11m3s
MAJOR: - LoadRepoPersonas: add MaxPersonaFileSize check before parsing to prevent resource exhaustion from oversized YAML files committed to target repositories MINOR: - isNotFoundError: tighten substring match to 'HTTP 404' only to avoid masking auth/transport errors containing generic 'not found' - main.go: remove duplicate flag.Parse() call - main.go: add comment explaining nil map indexing is safe in Go when LoadRepoPersonas returns an error Tests updated to reflect the intentional behavior change in isNotFoundError and added test case for oversized file rejection.
444 lines
12 KiB
Go
444 lines
12 KiB
Go
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParsePersonaBytes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data string
|
|
source string
|
|
wantName string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "valid yaml",
|
|
data: `name: test
|
|
identity: test identity
|
|
focus:
|
|
- testing
|
|
`,
|
|
source: "test.yaml",
|
|
wantName: "test",
|
|
},
|
|
{
|
|
name: "missing name",
|
|
data: "identity: test\n",
|
|
source: "test.yaml",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "invalid yaml",
|
|
data: "not: valid:\n yaml: [broken",
|
|
source: "test.yaml",
|
|
wantErr: "parse",
|
|
},
|
|
{
|
|
name: "json format by extension",
|
|
data: `{"name": "jsontest", "identity": "json identity"}`,
|
|
source: "test.json",
|
|
wantName: "jsontest",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p, err := ParsePersonaBytes([]byte(tt.data), tt.source)
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if p.Name != tt.wantName {
|
|
t.Errorf("Name = %q, want %q", p.Name, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockGiteaClient implements GiteaClient for testing.
|
|
type mockGiteaClient struct {
|
|
contents map[string][]ContentEntry // path -> entries
|
|
files map[string]string // path -> content
|
|
listErr error
|
|
fileErr map[string]error // path -> error
|
|
}
|
|
|
|
func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
|
if m.listErr != nil {
|
|
return nil, m.listErr
|
|
}
|
|
entries, ok := m.contents[path]
|
|
if !ok {
|
|
return nil, errors.New("list contents .review-bot/personas: HTTP 404: not found")
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
if m.fileErr != nil {
|
|
if err, ok := m.fileErr[filepath]; ok {
|
|
return "", err
|
|
}
|
|
}
|
|
content, ok := m.files[filepath]
|
|
if !ok {
|
|
return "", errors.New("HTTP 404: file not found")
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func TestLoadRepoPersonas(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("directory not found returns empty map", func(t *testing.T) {
|
|
client := &mockGiteaClient{} // No contents configured -> 404
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if personas == nil {
|
|
t.Error("expected empty map, got nil")
|
|
}
|
|
if len(personas) != 0 {
|
|
t.Errorf("expected 0 personas, got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("empty directory returns empty map", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {},
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 0 {
|
|
t.Errorf("expected 0 personas, got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("loads valid personas", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"},
|
|
{Name: "crypto.yaml", Path: ".review-bot/personas/crypto.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/trading.yaml": `name: trading
|
|
display_name: Trading Expert
|
|
identity: You are a trading expert.
|
|
focus:
|
|
- order handling
|
|
- risk management
|
|
`,
|
|
".review-bot/personas/crypto.yaml": `name: crypto
|
|
display_name: Crypto Expert
|
|
identity: You are a cryptography expert.
|
|
focus:
|
|
- key management
|
|
- encryption
|
|
`,
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 2 {
|
|
t.Fatalf("expected 2 personas, got %d", len(personas))
|
|
}
|
|
if personas["trading"] == nil {
|
|
t.Error("expected trading persona")
|
|
}
|
|
if personas["crypto"] == nil {
|
|
t.Error("expected crypto persona")
|
|
}
|
|
if personas["trading"].DisplayName != "Trading Expert" {
|
|
t.Errorf("trading display name = %q, want %q", personas["trading"].DisplayName, "Trading Expert")
|
|
}
|
|
})
|
|
|
|
t.Run("skips invalid persona files", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
|
|
{Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/valid.yaml": `name: valid
|
|
identity: Valid persona
|
|
`,
|
|
".review-bot/personas/invalid.yaml": "not valid yaml: [broken",
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should have the valid one, skip the invalid
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (skipped invalid), got %d", len(personas))
|
|
}
|
|
if personas["valid"] == nil {
|
|
t.Error("expected valid persona")
|
|
}
|
|
})
|
|
|
|
t.Run("skips non-yaml files", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
|
|
{Name: "README.md", Path: ".review-bot/personas/README.md", Type: "file"},
|
|
{Name: "notes.txt", Path: ".review-bot/personas/notes.txt", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/persona.yaml": `name: test
|
|
identity: Test persona
|
|
`,
|
|
".review-bot/personas/README.md": "# Personas\n\nPut your personas here.",
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (yaml only), got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("skips subdirectories", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
|
|
{Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/persona.yaml": `name: test
|
|
identity: Test persona
|
|
`,
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (files only), got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("propagates auth errors", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
listErr: errors.New("HTTP 401: unauthorized"),
|
|
}
|
|
_, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err == nil {
|
|
t.Fatal("expected error for auth failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") {
|
|
t.Errorf("error = %q, want containing '401'", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("skips files that fail to fetch", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "good.yaml", Path: ".review-bot/personas/good.yaml", Type: "file"},
|
|
{Name: "bad.yaml", Path: ".review-bot/personas/bad.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/good.yaml": `name: good
|
|
identity: Good persona
|
|
`,
|
|
},
|
|
fileErr: map[string]error{
|
|
".review-bot/personas/bad.yaml": errors.New("HTTP 500: internal server error"),
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (skipped failed fetch), got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("skips oversized files", func(t *testing.T) {
|
|
// Create a content string that exceeds MaxPersonaFileSize (64KB)
|
|
oversizedContent := strings.Repeat("a", MaxPersonaFileSize+1)
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "normal.yaml", Path: ".review-bot/personas/normal.yaml", Type: "file"},
|
|
{Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/normal.yaml": `name: normal
|
|
identity: Normal sized persona
|
|
`,
|
|
".review-bot/personas/huge.yaml": oversizedContent,
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should have the normal one, skip the oversized
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (skipped oversized), got %d", len(personas))
|
|
}
|
|
if personas["normal"] == nil {
|
|
t.Error("expected normal persona")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMergePersonas(t *testing.T) {
|
|
builtin := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Built-in security"},
|
|
"docs": {Name: "docs", Identity: "Built-in docs"},
|
|
}
|
|
repo := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Repo security override"},
|
|
"trading": {Name: "trading", Identity: "Repo trading"},
|
|
}
|
|
|
|
merged := MergePersonas(builtin, repo)
|
|
|
|
t.Run("repo overrides builtin on collision", func(t *testing.T) {
|
|
if merged["security"].Identity != "Repo security override" {
|
|
t.Errorf("security identity = %q, want repo override", merged["security"].Identity)
|
|
}
|
|
})
|
|
|
|
t.Run("builtin preserved when no collision", func(t *testing.T) {
|
|
if merged["docs"].Identity != "Built-in docs" {
|
|
t.Errorf("docs identity = %q, want built-in", merged["docs"].Identity)
|
|
}
|
|
})
|
|
|
|
t.Run("repo-only persona added", func(t *testing.T) {
|
|
if merged["trading"] == nil {
|
|
t.Error("expected trading persona from repo")
|
|
}
|
|
if merged["trading"].Identity != "Repo trading" {
|
|
t.Errorf("trading identity = %q, want repo", merged["trading"].Identity)
|
|
}
|
|
})
|
|
|
|
t.Run("original maps not modified", func(t *testing.T) {
|
|
if builtin["trading"] != nil {
|
|
t.Error("builtin map was modified")
|
|
}
|
|
if len(repo) != 2 {
|
|
t.Error("repo map was modified")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetBuiltinPersonasMap(t *testing.T) {
|
|
personas := GetBuiltinPersonasMap()
|
|
|
|
if len(personas) == 0 {
|
|
t.Fatal("expected at least one built-in persona")
|
|
}
|
|
|
|
// Verify expected personas exist
|
|
expected := []string{"security", "architect", "docs"}
|
|
for _, name := range expected {
|
|
if personas[name] == nil {
|
|
t.Errorf("expected built-in persona %q", name)
|
|
}
|
|
}
|
|
|
|
// Verify personas are valid
|
|
for name, p := range personas {
|
|
if p.Name != name {
|
|
t.Errorf("persona %q has mismatched name %q", name, p.Name)
|
|
}
|
|
if p.Identity == "" {
|
|
t.Errorf("persona %q has empty identity", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsYAMLFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
want bool
|
|
}{
|
|
{"test.yaml", true},
|
|
{"test.yml", true},
|
|
{"test.YAML", true},
|
|
{"test.YML", true},
|
|
{"test.json", false},
|
|
{"test.md", false},
|
|
{"test.txt", false},
|
|
{"yaml", false},
|
|
{"yaml.md", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isYAMLFile(tt.name); got != tt.want {
|
|
t.Errorf("isYAMLFile(%q) = %v, want %v", tt.name, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNotFoundError(t *testing.T) {
|
|
tests := []struct {
|
|
err error
|
|
want bool
|
|
}{
|
|
{nil, false},
|
|
{errors.New("HTTP 404: not found"), true},
|
|
{errors.New("HTTP 404"), true},
|
|
// Intentionally false: generic "not found" could mask auth/transport errors.
|
|
// Only explicit HTTP 404 responses should be treated as "directory doesn't exist".
|
|
{errors.New("something not found"), false},
|
|
{errors.New("HTTP 401: unauthorized"), false},
|
|
{errors.New("connection refused"), false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
name := "nil"
|
|
if tt.err != nil {
|
|
name = tt.err.Error()
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
if got := isNotFoundError(tt.err); got != tt.want {
|
|
t.Errorf("isNotFoundError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|