feat: load personas from target repo .review-bot/personas/
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 8m12s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 8m15s
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Failing after 42s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 8m12s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 8m15s
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Failing after 42s
Adds support for repository-specific personas. When --persona is specified, review-bot now: 1. Checks the target repo's .review-bot/personas/<name>.yaml directory 2. Falls back to built-in persona if not found in repo This allows repos to define domain-specific personas (trading, regulatory, etc.) or override built-in personas with project-specific rules, without requiring changes to CI configuration. Implementation: - New review.PersonaFetcher interface for abstracting Gitea API access - review.LoadRemotePersonas() with graceful fallback on 404 - review.MergePersonas() for combining remote and built-in personas - giteaFetcher adapter in main.go to bridge gitea.Client The feature follows a partial-success model: invalid YAML files or network errors for individual persona files are logged and skipped, allowing other valid personas to load. Closes #60
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
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"), true},
|
||||
{"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user