package review import ( "context" "log/slog" "strings" "gitea.weiker.me/rodin/review-bot/vcs" ) // RepoPersonaPath is the directory path where repo-specific personas are stored. const RepoPersonaPath = ".review-bot/personas" // LoadRepoPersonas fetches personas from a repository's .review-bot/personas/ directory. // Returns an empty map (not nil) if the directory doesn't exist or is empty. // Individual parse failures are logged and skipped; the remaining personas are still returned. // Auth errors and other non-404 errors are propagated. // Files exceeding MaxPersonaFileSize are rejected to prevent resource exhaustion. func LoadRepoPersonas(ctx context.Context, client vcs.FileReader, owner, repo string) (map[string]*Persona, error) { result := make(map[string]*Persona) entries, err := client.ListContents(ctx, owner, repo, RepoPersonaPath) if err != nil { // Check if this is a 404 (directory doesn't exist) - expected case if isNotFoundError(err) { slog.Debug("no repo personas directory found", "repo", owner+"/"+repo) return result, nil } // Other errors (auth, server) should propagate return nil, err } if len(entries) == 0 { slog.Debug("repo personas directory is empty", "repo", owner+"/"+repo) return result, nil } for _, entry := range entries { if entry.Type != "file" { continue } // Only process YAML files if !isYAMLFile(entry.Name) { continue } content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "") if err != nil { slog.Warn("could not fetch repo persona file", "file", entry.Path, "repo", owner+"/"+repo, "error", err) continue } // Enforce size limit before parsing to prevent resource exhaustion if len(content) > MaxPersonaFileSize { slog.Warn("repo persona file exceeds maximum size", "file", entry.Path, "repo", owner+"/"+repo, "size", len(content), "max", MaxPersonaFileSize) continue } persona, err := ParsePersonaBytes([]byte(content), entry.Path) if err != nil { slog.Warn("could not parse repo persona file", "file", entry.Path, "repo", owner+"/"+repo, "error", err) continue } result[persona.Name] = persona slog.Debug("loaded repo persona", "name", persona.Name, "file", entry.Path, "repo", owner+"/"+repo) } return result, nil } // MergePersonas combines built-in personas with repo personas. // Repo personas take precedence on name collision. // Returns a new map; inputs are not modified. func MergePersonas(builtin, repo map[string]*Persona) map[string]*Persona { result := make(map[string]*Persona, len(builtin)+len(repo)) // Copy built-in personas first for name, p := range builtin { result[name] = p } // Overlay repo personas (override on collision) for name, p := range repo { if _, exists := result[name]; exists { slog.Debug("repo persona overrides built-in", "name", name) } result[name] = p } return result } // GetBuiltinPersonasMap returns all built-in personas as a map keyed by name. // Returns an empty map (not nil) if loading fails. func GetBuiltinPersonasMap() map[string]*Persona { result := make(map[string]*Persona) for _, name := range ListBuiltinPersonas() { p, err := LoadBuiltinPersona(name) if err != nil { slog.Warn("could not load built-in persona", "name", name, "error", err) continue } result[name] = p } return result } // isYAMLFile checks if a filename has a YAML extension. func isYAMLFile(name string) bool { lower := strings.ToLower(name) return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") } // isNotFoundError checks if an error represents a 404 response. // This uses a specific "HTTP 404" substring match rather than a generic "not found" // match to avoid masking authentication failures or transport errors that might // contain "not found" in their message. func isNotFoundError(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "HTTP 404") }