5fac8bc505
CI / test (pull_request) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 27s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m5s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m40s
- Remove duplicate flag.Parse() call - Fix nil map panic in LoadRemotePersonas error path by assigning empty map when LoadRemotePersonas returns an error - Tighten isNotFoundError to only check HTTP 404 (remove broad 'not found' substring check to avoid false positives) - Clean up personaErr variable scope using narrower-scoped err variables - Add proper doc comment to LoadRemotePersonasFromPath (Go convention) - Add file count cap (50 files) in LoadRemotePersonasFromPath to prevent resource exhaustion from repos with thousands of small files - Update test expectation for tightened isNotFoundError
395 lines
11 KiB
Go
395 lines
11 KiB
Go
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// mockFetcher implements PersonaFetcher for testing.
|
|
type mockFetcher struct {
|
|
contents map[string][]ContentEntry // path -> entries
|
|
files map[string]string // path -> content
|
|
listErr error // error to return from ListContents
|
|
getFileErr map[string]error // path -> error for GetFileContent
|
|
listNotFound bool // return 404-style error
|
|
}
|
|
|
|
func newMockFetcher() *mockFetcher {
|
|
return &mockFetcher{
|
|
contents: make(map[string][]ContentEntry),
|
|
files: make(map[string]string),
|
|
getFileErr: make(map[string]error),
|
|
}
|
|
}
|
|
|
|
func (m *mockFetcher) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
|
if m.listNotFound {
|
|
return nil, errors.New("HTTP 404: not found")
|
|
}
|
|
if m.listErr != nil {
|
|
return nil, m.listErr
|
|
}
|
|
entries, ok := m.contents[path]
|
|
if !ok {
|
|
return nil, errors.New("HTTP 404: not found")
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (m *mockFetcher) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
if err, ok := m.getFileErr[filepath]; ok {
|
|
return "", err
|
|
}
|
|
content, ok := m.files[filepath]
|
|
if !ok {
|
|
return "", errors.New("HTTP 404: file not found")
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func TestLoadRemotePersonas_NoDirectory(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.listNotFound = true
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("expected no error for missing directory, got: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty map, got %d personas", len(result))
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_EmptyDirectory(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{}
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty map, got %d personas", len(result))
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_SinglePersona(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"},
|
|
}
|
|
fetcher.files[".review-bot/personas/trading.yaml"] = `
|
|
name: trading
|
|
display_name: Trading Expert
|
|
identity: You are a trading systems expert.
|
|
focus:
|
|
- order execution
|
|
- market data
|
|
`
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 persona, got %d", len(result))
|
|
}
|
|
if result["trading"] == nil {
|
|
t.Fatal("expected 'trading' persona")
|
|
}
|
|
if result["trading"].DisplayName != "Trading Expert" {
|
|
t.Errorf("expected display name 'Trading Expert', got %q", result["trading"].DisplayName)
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_MultiplePersonas(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "one.yaml", Path: ".review-bot/personas/one.yaml", Type: "file"},
|
|
{Name: "two.yml", Path: ".review-bot/personas/two.yml", Type: "file"},
|
|
}
|
|
fetcher.files[".review-bot/personas/one.yaml"] = `
|
|
name: one
|
|
identity: First persona.
|
|
`
|
|
fetcher.files[".review-bot/personas/two.yml"] = `
|
|
name: two
|
|
identity: Second persona.
|
|
`
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 personas, got %d", len(result))
|
|
}
|
|
if result["one"] == nil || result["two"] == nil {
|
|
t.Error("expected both personas to be loaded")
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_SkipsNonYAML(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
|
|
{Name: "readme.md", Path: ".review-bot/personas/readme.md", Type: "file"},
|
|
{Name: "config.json", Path: ".review-bot/personas/config.json", Type: "file"},
|
|
}
|
|
fetcher.files[".review-bot/personas/valid.yaml"] = `
|
|
name: valid
|
|
identity: Valid persona.
|
|
`
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 persona (skipping non-YAML), got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_SkipsDirectories(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
|
|
{Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"},
|
|
}
|
|
fetcher.files[".review-bot/personas/valid.yaml"] = `
|
|
name: valid
|
|
identity: Valid persona.
|
|
`
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 persona (skipping dir), got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_SkipsInvalidYAML(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
|
|
{Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"},
|
|
}
|
|
fetcher.files[".review-bot/personas/valid.yaml"] = `
|
|
name: valid
|
|
identity: Valid persona.
|
|
`
|
|
fetcher.files[".review-bot/personas/invalid.yaml"] = `
|
|
this is not valid yaml: [unclosed bracket
|
|
`
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 persona (skipping invalid), got %d", len(result))
|
|
}
|
|
if result["valid"] == nil {
|
|
t.Error("expected valid persona to be loaded")
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_SkipsOversizedFiles(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"},
|
|
}
|
|
// Create content larger than MaxPersonaFileSize (64KB)
|
|
fetcher.files[".review-bot/personas/huge.yaml"] = `
|
|
name: huge
|
|
identity: ` + string(make([]byte, MaxPersonaFileSize+1000))
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected 0 personas (oversized file skipped), got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_SkipsFetchErrors(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
|
|
{Name: "error.yaml", Path: ".review-bot/personas/error.yaml", Type: "file"},
|
|
}
|
|
fetcher.files[".review-bot/personas/valid.yaml"] = `
|
|
name: valid
|
|
identity: Valid persona.
|
|
`
|
|
fetcher.getFileErr[".review-bot/personas/error.yaml"] = errors.New("network error")
|
|
|
|
result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 persona (skipping error), got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_ListContentsError(t *testing.T) {
|
|
fetcher := newMockFetcher()
|
|
fetcher.listErr = errors.New("server error")
|
|
|
|
_, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo")
|
|
if err == nil {
|
|
t.Fatal("expected error for list contents failure")
|
|
}
|
|
}
|
|
|
|
func TestLoadRemotePersonas_ContextCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
fetcher := newMockFetcher()
|
|
fetcher.contents[DefaultPersonasPath] = []ContentEntry{
|
|
{Name: "one.yaml", Path: ".review-bot/personas/one.yaml", Type: "file"},
|
|
}
|
|
fetcher.files[".review-bot/personas/one.yaml"] = `
|
|
name: one
|
|
identity: One.
|
|
`
|
|
|
|
_, err := LoadRemotePersonas(ctx, fetcher, "owner", "repo")
|
|
if err == nil {
|
|
t.Fatal("expected context cancellation error")
|
|
}
|
|
}
|
|
|
|
func TestMergePersonas_NoOverlap(t *testing.T) {
|
|
remote := map[string]*Persona{
|
|
"trading": {Name: "trading", Identity: "Trading expert."},
|
|
}
|
|
builtin := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Security expert."},
|
|
}
|
|
|
|
merged, names := MergePersonas(remote, builtin)
|
|
|
|
if len(merged) != 2 {
|
|
t.Fatalf("expected 2 personas, got %d", len(merged))
|
|
}
|
|
if len(names) != 2 {
|
|
t.Fatalf("expected 2 names, got %d", len(names))
|
|
}
|
|
// Names should be sorted
|
|
if names[0] != "security" || names[1] != "trading" {
|
|
t.Errorf("expected sorted names [security, trading], got %v", names)
|
|
}
|
|
}
|
|
|
|
func TestMergePersonas_RemoteOverridesBuiltin(t *testing.T) {
|
|
remote := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Custom security expert."},
|
|
}
|
|
builtin := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Default security expert."},
|
|
}
|
|
|
|
merged, _ := MergePersonas(remote, builtin)
|
|
|
|
if merged["security"].Identity != "Custom security expert." {
|
|
t.Errorf("expected remote to override builtin, got identity: %q", merged["security"].Identity)
|
|
}
|
|
}
|
|
|
|
func TestMergePersonas_EmptyRemote(t *testing.T) {
|
|
remote := map[string]*Persona{}
|
|
builtin := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Security."},
|
|
}
|
|
|
|
merged, names := MergePersonas(remote, builtin)
|
|
|
|
if len(merged) != 1 {
|
|
t.Fatalf("expected 1 persona, got %d", len(merged))
|
|
}
|
|
if names[0] != "security" {
|
|
t.Errorf("expected 'security', got %q", names[0])
|
|
}
|
|
}
|
|
|
|
func TestMergePersonas_EmptyBuiltin(t *testing.T) {
|
|
remote := map[string]*Persona{
|
|
"trading": {Name: "trading", Identity: "Trading."},
|
|
}
|
|
builtin := map[string]*Persona{}
|
|
|
|
merged, names := MergePersonas(remote, builtin)
|
|
|
|
if len(merged) != 1 {
|
|
t.Fatalf("expected 1 persona, got %d", len(merged))
|
|
}
|
|
if names[0] != "trading" {
|
|
t.Errorf("expected 'trading', got %q", names[0])
|
|
}
|
|
}
|
|
|
|
func TestLoadAllBuiltinPersonas(t *testing.T) {
|
|
personas := LoadAllBuiltinPersonas()
|
|
|
|
// Should load at least the known built-in personas
|
|
expected := []string{"architect", "docs", "security"}
|
|
for _, name := range expected {
|
|
if personas[name] == nil {
|
|
t.Errorf("expected built-in persona %q to be loaded", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsYAMLFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expected bool
|
|
}{
|
|
{"test.yaml", true},
|
|
{"test.yml", true},
|
|
{"test.YAML", true},
|
|
{"test.YML", true},
|
|
{"test.json", false},
|
|
{"test.md", false},
|
|
{"yaml", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isYAMLFile(tc.name); got != tc.expected {
|
|
t.Errorf("isYAMLFile(%q) = %v, want %v", tc.name, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNotFoundError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expected bool
|
|
}{
|
|
{"nil error", nil, false},
|
|
{"HTTP 404", errors.New("HTTP 404: not found"), true},
|
|
{"not found text", errors.New("path not found"), false},
|
|
{"server error", errors.New("server error"), false},
|
|
{"HTTP 500", errors.New("HTTP 500: internal error"), false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isNotFoundError(tc.err); got != tc.expected {
|
|
t.Errorf("isNotFoundError(%v) = %v, want %v", tc.err, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|