57e62a345f
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 9m31s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 10m3s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 11m30s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 10m56s
Add persona system for specialized review roles. Each persona defines: - A specific review focus (security, architecture, documentation) - Custom system prompt additions - Personality/tone adjustments Built-in personas: security, architect, docs Custom personas: load from JSON via persona-file flag Includes workspace validation to prevent path traversal attacks. Closes #51
115 lines
3.3 KiB
Go
115 lines
3.3 KiB
Go
package review
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
//go:embed personas/*.yaml
|
|
var embeddedPersonas embed.FS
|
|
|
|
// Persona defines a specialized review role with focused expertise.
|
|
type Persona struct {
|
|
Name string `json:"name" yaml:"name"`
|
|
DisplayName string `json:"display_name" yaml:"display_name"`
|
|
ModelPref string `json:"model_preference,omitempty" yaml:"model_preference,omitempty"`
|
|
Identity string `json:"identity" yaml:"identity"`
|
|
Focus []string `json:"focus" yaml:"focus"`
|
|
Ignore []string `json:"ignore" yaml:"ignore"`
|
|
Severity Severity `json:"severity" yaml:"severity"`
|
|
OutputFormat string `json:"output_format,omitempty" yaml:"output_format,omitempty"`
|
|
}
|
|
|
|
// Severity defines what constitutes each severity level for this persona.
|
|
// These are prompt guidance for the LLM, not output format changes.
|
|
type Severity struct {
|
|
Major string `json:"major" yaml:"major"`
|
|
Minor string `json:"minor" yaml:"minor"`
|
|
Nit string `json:"nit" yaml:"nit"`
|
|
}
|
|
|
|
// LoadPersona loads a persona from a file path.
|
|
// Supports both YAML (.yaml, .yml) and JSON (.json) formats.
|
|
func LoadPersona(path string) (*Persona, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read persona file %s: %w", path, err)
|
|
}
|
|
return parsePersona(data, path)
|
|
}
|
|
|
|
// LoadBuiltinPersona loads a built-in persona by name.
|
|
// Returns an error if the persona doesn't exist.
|
|
func LoadBuiltinPersona(name string) (*Persona, error) {
|
|
filename := name + ".yaml"
|
|
data, err := embeddedPersonas.ReadFile("personas/" + filename) // embed.FS paths use forward slashes per io/fs spec
|
|
if err != nil {
|
|
available := ListBuiltinPersonas()
|
|
return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", "))
|
|
}
|
|
return parsePersona(data, "builtin:"+name)
|
|
}
|
|
|
|
// ListBuiltinPersonas returns the names of all built-in personas.
|
|
func ListBuiltinPersonas() []string {
|
|
entries, err := embeddedPersonas.ReadDir("personas")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if strings.HasSuffix(name, ".yaml") {
|
|
names = append(names, strings.TrimSuffix(name, ".yaml"))
|
|
} else if strings.HasSuffix(name, ".yml") {
|
|
names = append(names, strings.TrimSuffix(name, ".yml"))
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func parsePersona(data []byte, source string) (*Persona, error) {
|
|
var p Persona
|
|
|
|
// Determine format by extension or try YAML first (it's a superset of JSON)
|
|
ext := strings.ToLower(filepath.Ext(source))
|
|
if ext == ".json" {
|
|
if err := json.Unmarshal(data, &p); err != nil {
|
|
return nil, fmt.Errorf("parse persona %s: %w", source, err)
|
|
}
|
|
} else {
|
|
// YAML (also handles .yaml, .yml, and builtin: prefix)
|
|
if err := yaml.Unmarshal(data, &p); err != nil {
|
|
return nil, fmt.Errorf("parse persona %s: %w", source, err)
|
|
}
|
|
}
|
|
|
|
if err := validatePersona(&p, source); err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func validatePersona(p *Persona, source string) error {
|
|
if p.Name == "" {
|
|
return fmt.Errorf("persona %s: name is required", source)
|
|
}
|
|
if p.Identity == "" {
|
|
return fmt.Errorf("persona %s: identity is required", source)
|
|
}
|
|
// DisplayName defaults to Name if not set
|
|
if p.DisplayName == "" {
|
|
p.DisplayName = p.Name
|
|
}
|
|
return nil
|
|
}
|