feat(persona): add role-based review personas
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 9m33s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 10m0s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 10m47s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 11m34s
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 9m33s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 10m0s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 10m47s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 11m34s
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
This commit is contained in:
+32
-17
@@ -7,10 +7,37 @@ import (
|
||||
|
||||
// FormatMarkdown formats a ReviewResult into the markdown body for a Gitea review.
|
||||
func FormatMarkdown(result *ReviewResult, reviewerName string) string {
|
||||
return FormatMarkdownWithDisplay(result, reviewerName, reviewerName)
|
||||
}
|
||||
|
||||
// GiteaEvent converts the verdict to the Gitea API event string.
|
||||
func GiteaEvent(verdict string) string {
|
||||
switch verdict {
|
||||
case "APPROVE":
|
||||
return "APPROVED"
|
||||
case "REQUEST_CHANGES":
|
||||
return "REQUEST_CHANGES"
|
||||
default:
|
||||
return "COMMENT"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatMarkdownWithDisplay formats a ReviewResult with separate display name and sentinel name.
|
||||
// Note: displayName is not HTML-escaped as Gitea sanitizes rendered Markdown.
|
||||
// Persona display names are controlled by repo owners (trusted input).
|
||||
// displayName is used for the header title, sentinelName is used for the cleanup sentinel.
|
||||
// If displayName is empty, sentinelName is used for both.
|
||||
func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if reviewerName != "" {
|
||||
title := strings.ToUpper(reviewerName[:1]) + reviewerName[1:]
|
||||
// Use display name for header, or fall back to sentinel name
|
||||
headerName := displayName
|
||||
if headerName == "" {
|
||||
headerName = sentinelName
|
||||
}
|
||||
|
||||
if headerName != "" {
|
||||
title := strings.ToUpper(headerName[:1]) + headerName[1:]
|
||||
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
|
||||
}
|
||||
|
||||
@@ -33,23 +60,11 @@ func FormatMarkdown(result *ReviewResult, reviewerName string) string {
|
||||
sb.WriteString("## Recommendation\n\n")
|
||||
sb.WriteString(fmt.Sprintf("**%s** — %s\n", result.Verdict, result.Recommendation))
|
||||
|
||||
if reviewerName != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n---\n*Review by %s*\n", reviewerName))
|
||||
if sentinelName != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n---\n*Review by %s*\n", headerName))
|
||||
// Hidden sentinel for identifying this bot's reviews during cleanup
|
||||
sb.WriteString(fmt.Sprintf("\n<!-- review-bot:%s -->\n", reviewerName))
|
||||
sb.WriteString(fmt.Sprintf("\n<!-- review-bot:%s -->\n", sentinelName))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GiteaEvent converts the verdict to the Gitea API event string.
|
||||
func GiteaEvent(verdict string) string {
|
||||
switch verdict {
|
||||
case "APPROVE":
|
||||
return "APPROVED"
|
||||
case "REQUEST_CHANGES":
|
||||
return "REQUEST_CHANGES"
|
||||
default:
|
||||
return "COMMENT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,3 +159,58 @@ func TestFormatMarkdown_RoleTitle(t *testing.T) {
|
||||
t.Error("should not contain role title header when reviewer name is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMarkdownWithDisplay(t *testing.T) {
|
||||
result := &ReviewResult{
|
||||
Verdict: "APPROVE",
|
||||
Summary: "Test summary",
|
||||
Findings: nil,
|
||||
Recommendation: "Test recommendation",
|
||||
}
|
||||
|
||||
t.Run("with display name", func(t *testing.T) {
|
||||
body := FormatMarkdownWithDisplay(result, "Security Specialist", "security")
|
||||
|
||||
// Header should use display name
|
||||
if !strings.Contains(body, "# Security Specialist Review") {
|
||||
t.Error("header should use display name")
|
||||
}
|
||||
|
||||
// Sentinel should use sentinel name
|
||||
if !strings.Contains(body, "<!-- review-bot:security -->") {
|
||||
t.Error("sentinel should use sentinel name")
|
||||
}
|
||||
|
||||
// Footer "Review by" should use display name
|
||||
if !strings.Contains(body, "*Review by Security Specialist*") {
|
||||
t.Error("footer should use display name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without display name", func(t *testing.T) {
|
||||
body := FormatMarkdownWithDisplay(result, "", "reviewer")
|
||||
|
||||
// Should fall back to sentinel name for header
|
||||
if !strings.Contains(body, "# Reviewer Review") {
|
||||
t.Error("header should fall back to sentinel name")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "<!-- review-bot:reviewer -->") {
|
||||
t.Error("sentinel should use sentinel name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty both names", func(t *testing.T) {
|
||||
body := FormatMarkdownWithDisplay(result, "", "")
|
||||
|
||||
// Should not have header
|
||||
if strings.Contains(body, "# ") && strings.Contains(body, " Review") {
|
||||
t.Error("should not have header when both names empty")
|
||||
}
|
||||
|
||||
// Should not have sentinel
|
||||
if strings.Contains(body, "<!-- review-bot:") {
|
||||
t.Error("should not have sentinel when sentinel name empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildPersonaSystemPrompt constructs a system prompt from a persona definition.
|
||||
// This replaces BuildSystemBase when a persona is provided.
|
||||
func BuildPersonaSystemPrompt(p *Persona) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Identity section
|
||||
sb.WriteString(p.Identity)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Focus section
|
||||
if len(p.Focus) > 0 {
|
||||
sb.WriteString("## Focus Areas\n\n")
|
||||
sb.WriteString("Concentrate your review on:\n")
|
||||
for _, f := range p.Focus {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", f))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Ignore section
|
||||
if len(p.Ignore) > 0 {
|
||||
sb.WriteString("## Explicitly Out of Scope\n\n")
|
||||
sb.WriteString("Do NOT comment on:\n")
|
||||
for _, i := range p.Ignore {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", i))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Severity calibration
|
||||
if p.Severity.Major != "" || p.Severity.Minor != "" || p.Severity.Nit != "" {
|
||||
sb.WriteString("## Severity Calibration\n\n")
|
||||
sb.WriteString("Use these severity definitions for YOUR domain:\n")
|
||||
if p.Severity.Major != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **MAJOR**: %s\n", p.Severity.Major))
|
||||
}
|
||||
if p.Severity.Minor != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **MINOR**: %s\n", p.Severity.Minor))
|
||||
}
|
||||
if p.Severity.Nit != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **NIT**: %s\n", p.Severity.Nit))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Output format instructions (shared schema from prompt.go)
|
||||
sb.WriteString("## Review Instructions\n\n")
|
||||
sb.WriteString("CONTEXT:\n")
|
||||
sb.WriteString("- You will receive the full content of modified files for reference, followed by the diff showing what changed.\n")
|
||||
sb.WriteString("- The diff shows ONLY what was added/removed. The full file content provides complete context.\n")
|
||||
sb.WriteString("- Focus your review on the CHANGES (the diff), using the full files for context.\n\n")
|
||||
sb.WriteString("Your task:\n")
|
||||
sb.WriteString("1. Review the diff for issues within YOUR focus areas only.\n")
|
||||
sb.WriteString("2. Consider the CI status — if CI has failed, that is an automatic REQUEST_CHANGES regardless of code quality.\n")
|
||||
sb.WriteString("3. Output your review as structured JSON (and ONLY JSON, no markdown fences or other text).\n\n")
|
||||
sb.WriteString("Output format:\n")
|
||||
sb.WriteString(outputSchemaJSON)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(verdictRules)
|
||||
sb.WriteString("\n- Only report findings within your focus areas. Ignore everything else.\n")
|
||||
sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n")
|
||||
sb.WriteString("- If the diff has no changes relevant to your focus areas, APPROVE with no findings.\n")
|
||||
|
||||
// Custom output format if provided
|
||||
if p.OutputFormat != "" {
|
||||
sb.WriteString("\n\n## Additional Output Guidelines\n\n")
|
||||
sb.WriteString(p.OutputFormat)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildSystemPromptWithPersona constructs the full system prompt, using either
|
||||
// a persona or the default generic prompt. This is a convenience wrapper that
|
||||
// combines BuildPersonaSystemPrompt (or BuildSystemBase) with patterns and conventions.
|
||||
// It is exported for use by callers who want one-shot prompt assembly.
|
||||
func BuildSystemPromptWithPersona(persona *Persona, conventions, patterns string) string {
|
||||
var base string
|
||||
if persona != nil {
|
||||
base = BuildPersonaSystemPrompt(persona)
|
||||
} else {
|
||||
base = BuildSystemBase()
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(base)
|
||||
|
||||
if patterns != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n\n## Language Patterns & Idioms\n\nUse the following patterns as review criteria. Code that violates these established patterns is a finding:\n\n%s\n", patterns))
|
||||
}
|
||||
|
||||
if conventions != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n\n## Repository Conventions\n\nThe repository has the following coding conventions that must be respected:\n\n%s\n", conventions))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildPersonaSystemPrompt(t *testing.T) {
|
||||
p := &Persona{
|
||||
Name: "security",
|
||||
DisplayName: "Security Specialist",
|
||||
Identity: "You are a security specialist.",
|
||||
Focus: []string{"injection attacks", "auth bypass"},
|
||||
Ignore: []string{"code style", "performance"},
|
||||
Severity: Severity{
|
||||
Major: "exploitable vulnerabilities",
|
||||
Minor: "defense in depth",
|
||||
Nit: "theoretical risks",
|
||||
},
|
||||
}
|
||||
|
||||
prompt := BuildPersonaSystemPrompt(p)
|
||||
|
||||
// Check identity is included
|
||||
if !strings.Contains(prompt, "You are a security specialist.") {
|
||||
t.Error("prompt should contain identity")
|
||||
}
|
||||
|
||||
// Check focus areas
|
||||
if !strings.Contains(prompt, "Focus Areas") {
|
||||
t.Error("prompt should contain Focus Areas section")
|
||||
}
|
||||
if !strings.Contains(prompt, "injection attacks") {
|
||||
t.Error("prompt should contain focus item")
|
||||
}
|
||||
|
||||
// Check ignore section
|
||||
if !strings.Contains(prompt, "Out of Scope") {
|
||||
t.Error("prompt should contain Out of Scope section")
|
||||
}
|
||||
if !strings.Contains(prompt, "code style") {
|
||||
t.Error("prompt should contain ignore item")
|
||||
}
|
||||
|
||||
// Check severity calibration
|
||||
if !strings.Contains(prompt, "Severity Calibration") {
|
||||
t.Error("prompt should contain Severity Calibration section")
|
||||
}
|
||||
if !strings.Contains(prompt, "exploitable vulnerabilities") {
|
||||
t.Error("prompt should contain major severity definition")
|
||||
}
|
||||
|
||||
// Check JSON output format is included
|
||||
if !strings.Contains(prompt, `"verdict"`) {
|
||||
t.Error("prompt should contain JSON output format")
|
||||
}
|
||||
if !strings.Contains(prompt, "APPROVE") {
|
||||
t.Error("prompt should mention APPROVE verdict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPersonaSystemPromptMinimal(t *testing.T) {
|
||||
// Minimal persona with only required fields
|
||||
p := &Persona{
|
||||
Name: "minimal",
|
||||
Identity: "You are a minimal reviewer.",
|
||||
}
|
||||
|
||||
prompt := BuildPersonaSystemPrompt(p)
|
||||
|
||||
// Should still work without optional fields
|
||||
if !strings.Contains(prompt, "You are a minimal reviewer.") {
|
||||
t.Error("prompt should contain identity")
|
||||
}
|
||||
|
||||
// Should not have empty sections
|
||||
if strings.Contains(prompt, "Focus Areas") && !strings.Contains(prompt, "Concentrate your review on:") {
|
||||
t.Error("should not have Focus Areas header without content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptWithPersona(t *testing.T) {
|
||||
t.Run("with persona", func(t *testing.T) {
|
||||
p := &Persona{
|
||||
Name: "test",
|
||||
Identity: "Test persona identity.",
|
||||
Focus: []string{"testing"},
|
||||
}
|
||||
|
||||
prompt := BuildSystemPromptWithPersona(p, "test conventions", "test patterns")
|
||||
|
||||
if !strings.Contains(prompt, "Test persona identity.") {
|
||||
t.Error("should contain persona identity")
|
||||
}
|
||||
if !strings.Contains(prompt, "test conventions") {
|
||||
t.Error("should contain conventions")
|
||||
}
|
||||
if !strings.Contains(prompt, "test patterns") {
|
||||
t.Error("should contain patterns")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without persona", func(t *testing.T) {
|
||||
prompt := BuildSystemPromptWithPersona(nil, "test conventions", "test patterns")
|
||||
|
||||
// Should use default system base
|
||||
if !strings.Contains(prompt, "expert code reviewer") {
|
||||
t.Error("should contain default system base when no persona")
|
||||
}
|
||||
if !strings.Contains(prompt, "test conventions") {
|
||||
t.Error("should contain conventions")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty conventions and patterns", func(t *testing.T) {
|
||||
p := &Persona{
|
||||
Name: "test",
|
||||
Identity: "Test identity.",
|
||||
}
|
||||
|
||||
prompt := BuildSystemPromptWithPersona(p, "", "")
|
||||
|
||||
if strings.Contains(prompt, "Language Patterns") {
|
||||
t.Error("should not contain patterns section when empty")
|
||||
}
|
||||
if strings.Contains(prompt, "Repository Conventions") {
|
||||
t.Error("should not contain conventions section when empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPersonaPromptContainsOutputRules(t *testing.T) {
|
||||
p := &Persona{
|
||||
Name: "test",
|
||||
Identity: "Test.",
|
||||
}
|
||||
|
||||
prompt := BuildPersonaSystemPrompt(p)
|
||||
|
||||
// Must contain the critical output rules
|
||||
requiredStrings := []string{
|
||||
"APPROVE",
|
||||
"REQUEST_CHANGES",
|
||||
"MAJOR",
|
||||
"MINOR",
|
||||
"NIT",
|
||||
"verdict",
|
||||
"findings",
|
||||
"CI",
|
||||
}
|
||||
|
||||
for _, s := range requiredStrings {
|
||||
if !strings.Contains(prompt, s) {
|
||||
t.Errorf("prompt should contain %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadBuiltinPersona(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
personaName string
|
||||
wantErr bool
|
||||
wantDisplay string
|
||||
}{
|
||||
{
|
||||
name: "security persona",
|
||||
personaName: "security",
|
||||
wantErr: false,
|
||||
wantDisplay: "Security Specialist",
|
||||
},
|
||||
{
|
||||
name: "architect persona",
|
||||
personaName: "architect",
|
||||
wantErr: false,
|
||||
wantDisplay: "Software Architect",
|
||||
},
|
||||
{
|
||||
name: "docs persona",
|
||||
personaName: "docs",
|
||||
wantErr: false,
|
||||
wantDisplay: "Documentation Reviewer",
|
||||
},
|
||||
{
|
||||
name: "unknown persona",
|
||||
personaName: "nonexistent",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p, err := LoadBuiltinPersona(tt.personaName)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if p.Name != tt.personaName {
|
||||
t.Errorf("Name = %q, want %q", p.Name, tt.personaName)
|
||||
}
|
||||
if p.DisplayName != tt.wantDisplay {
|
||||
t.Errorf("DisplayName = %q, want %q", p.DisplayName, tt.wantDisplay)
|
||||
}
|
||||
if p.Identity == "" {
|
||||
t.Error("Identity should not be empty")
|
||||
}
|
||||
if len(p.Focus) == 0 {
|
||||
t.Error("Focus should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuiltinPersonas(t *testing.T) {
|
||||
names := ListBuiltinPersonas()
|
||||
if len(names) == 0 {
|
||||
t.Fatal("expected at least one built-in persona")
|
||||
}
|
||||
|
||||
// Check for expected personas
|
||||
expected := map[string]bool{"security": false, "architect": false, "docs": false}
|
||||
for _, name := range names {
|
||||
if _, ok := expected[name]; ok {
|
||||
expected[name] = true
|
||||
}
|
||||
}
|
||||
for name, found := range expected {
|
||||
if !found {
|
||||
t.Errorf("expected built-in persona %q not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaFromYAMLFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.yaml")
|
||||
|
||||
content := `
|
||||
name: test
|
||||
display_name: Test Persona
|
||||
identity: |
|
||||
You are a test persona.
|
||||
Multi-line identity works.
|
||||
focus:
|
||||
- testing
|
||||
- validation
|
||||
ignore:
|
||||
- nothing
|
||||
severity:
|
||||
major: Big problems
|
||||
minor: Small problems
|
||||
nit: Tiny problems
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
p, err := LoadPersona(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPersona failed: %v", err)
|
||||
}
|
||||
|
||||
if p.Name != "test" {
|
||||
t.Errorf("Name = %q, want %q", p.Name, "test")
|
||||
}
|
||||
if p.DisplayName != "Test Persona" {
|
||||
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
||||
}
|
||||
if len(p.Focus) != 2 {
|
||||
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
||||
}
|
||||
if !strings.Contains(p.Identity, "Multi-line") {
|
||||
t.Error("Identity should contain multi-line content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaFromJSONFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
|
||||
content := `{
|
||||
"name": "test",
|
||||
"display_name": "Test Persona",
|
||||
"identity": "You are a test persona.",
|
||||
"focus": ["testing"],
|
||||
"ignore": ["nothing"],
|
||||
"severity": {
|
||||
"major": "Big problems",
|
||||
"minor": "Small problems",
|
||||
"nit": "Tiny problems"
|
||||
}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
p, err := LoadPersona(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPersona failed: %v", err)
|
||||
}
|
||||
|
||||
if p.Name != "test" {
|
||||
t.Errorf("Name = %q, want %q", p.Name, "test")
|
||||
}
|
||||
if p.DisplayName != "Test Persona" {
|
||||
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing name",
|
||||
yaml: "identity: test",
|
||||
wantErr: "name is required",
|
||||
},
|
||||
{
|
||||
name: "missing identity",
|
||||
yaml: "name: test",
|
||||
wantErr: "identity is required",
|
||||
},
|
||||
{
|
||||
name: "display_name defaults to name",
|
||||
yaml: "name: test\nidentity: test identity",
|
||||
// No error expected - should succeed
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.yaml")
|
||||
if err := os.WriteFile(path, []byte(tt.yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
p, err := LoadPersona(path)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Check display_name defaulting
|
||||
if p.DisplayName == "" {
|
||||
t.Error("DisplayName should default to Name")
|
||||
}
|
||||
if p.DisplayName != p.Name {
|
||||
t.Errorf("DisplayName should default to Name, got %q", p.DisplayName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaFileNotFound(t *testing.T) {
|
||||
_, err := LoadPersona("/nonexistent/path/persona.yaml")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaInvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "invalid.yaml")
|
||||
if err := os.WriteFile(path, []byte("not: valid: yaml: here"), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: architect
|
||||
display_name: Software Architect
|
||||
|
||||
identity: |
|
||||
You are a software architect reviewing code for design quality.
|
||||
|
||||
Your expertise:
|
||||
- Design patterns and anti-patterns
|
||||
- Code organization and module boundaries
|
||||
- API design and contracts
|
||||
- Testability and dependency injection
|
||||
- Consistency with existing architecture
|
||||
- Technical debt identification
|
||||
|
||||
focus:
|
||||
- Design pattern violations or misuse
|
||||
- Module boundary violations (inappropriate coupling)
|
||||
- API design issues (unclear contracts, leaky abstractions)
|
||||
- Testability problems (hidden dependencies, god objects)
|
||||
- Inconsistency with existing codebase patterns
|
||||
- Unnecessary complexity or over-engineering
|
||||
- Missing abstractions or premature abstraction
|
||||
|
||||
ignore:
|
||||
- Security vulnerabilities (security persona handles these)
|
||||
- Performance micro-optimizations
|
||||
- Code style and formatting
|
||||
- Documentation typos
|
||||
- Test implementation details
|
||||
|
||||
severity:
|
||||
major: "Architectural violations that will cause maintenance problems or make the codebase harder to evolve"
|
||||
minor: "Design issues that reduce clarity or testability but don't block progress"
|
||||
nit: "Minor pattern deviations or style preferences"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: docs
|
||||
display_name: Documentation Reviewer
|
||||
|
||||
identity: |
|
||||
You are a documentation specialist reviewing code for clarity and documentation quality.
|
||||
|
||||
Your expertise:
|
||||
- API documentation and examples
|
||||
- Code comments and their accuracy
|
||||
- Error message clarity
|
||||
- README and guide quality
|
||||
- Naming clarity and self-documenting code
|
||||
|
||||
focus:
|
||||
- Missing or outdated documentation
|
||||
- Unclear or misleading comments
|
||||
- Poor error messages (cryptic, unhelpful, missing context)
|
||||
- Confusing naming (functions, variables, types)
|
||||
- Missing examples for complex APIs
|
||||
- Inconsistent terminology
|
||||
- Documentation that contradicts the code
|
||||
|
||||
ignore:
|
||||
- Security vulnerabilities
|
||||
- Performance issues
|
||||
- Design patterns
|
||||
- Test coverage
|
||||
- Code style (unless it affects readability)
|
||||
|
||||
severity:
|
||||
major: "Documentation that actively misleads or missing docs for critical functionality"
|
||||
minor: "Unclear documentation or poor error messages that will confuse users"
|
||||
nit: "Minor clarity improvements or typo fixes"
|
||||
@@ -0,0 +1,34 @@
|
||||
name: security
|
||||
display_name: Security Specialist
|
||||
|
||||
identity: |
|
||||
You are a security specialist reviewing code for vulnerabilities.
|
||||
|
||||
Your expertise:
|
||||
- OWASP Top 10 vulnerabilities
|
||||
- Injection attacks (SQL, command, path traversal, template)
|
||||
- Authentication and authorization patterns
|
||||
- Secrets management and exposure risks
|
||||
- Race conditions with security implications
|
||||
- Event sourcing attack vectors (replay attacks, event injection)
|
||||
|
||||
focus:
|
||||
- Injection attacks (SQL, command, path traversal, template injection)
|
||||
- Authentication and authorization gaps or bypasses
|
||||
- Secrets exposure (hardcoded credentials, tokens in logs, config leaks)
|
||||
- Input validation failures (unsanitized input, unsafe deserialization)
|
||||
- Race conditions that could be exploited
|
||||
- Cryptographic weaknesses (weak algorithms, improper key handling)
|
||||
- Information disclosure through error messages or logs
|
||||
|
||||
ignore:
|
||||
- Code style and naming conventions
|
||||
- Performance optimizations (unless security-related)
|
||||
- Documentation quality
|
||||
- General code quality or readability
|
||||
- Test coverage
|
||||
|
||||
severity:
|
||||
major: "Exploitable vulnerabilities: auth bypass, injection, data exfiltration, privilege escalation, RCE"
|
||||
minor: "Defense-in-depth issues: missing rate limiting, verbose errors, weak input validation"
|
||||
nit: "Theoretical risks with low exploitability or impact"
|
||||
+26
-18
@@ -7,6 +7,28 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// outputSchemaJSON is the shared JSON output format specification used by both
|
||||
// the generic reviewer and persona-based reviewers.
|
||||
const outputSchemaJSON = `{
|
||||
"verdict": "APPROVE" or "REQUEST_CHANGES",
|
||||
"summary": "Brief overall assessment (1-3 sentences)",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "MAJOR" or "MINOR" or "NIT",
|
||||
"file": "path/to/file",
|
||||
"line": <line number from the diff>,
|
||||
"finding": "Description of the issue"
|
||||
}
|
||||
],
|
||||
"recommendation": "Full recommendation text explaining your verdict"
|
||||
}`
|
||||
|
||||
// verdictRules is the shared verdict determination rules.
|
||||
const verdictRules = `Rules:
|
||||
- If there are any MAJOR findings → verdict must be REQUEST_CHANGES
|
||||
- If there are no MAJOR findings → verdict should be APPROVE
|
||||
- If CI has failed → verdict must be REQUEST_CHANGES with a finding noting the CI failure`
|
||||
|
||||
// BuildSystemBase returns the core system prompt instructions without
|
||||
// patterns or conventions. Used by the budget package to separate
|
||||
// trimmable from non-trimmable content.
|
||||
@@ -23,24 +45,10 @@ func BuildSystemBase() string {
|
||||
sb.WriteString("2. Consider the CI status — if CI has failed, that is an automatic REQUEST_CHANGES regardless of code quality.\n")
|
||||
sb.WriteString("3. Output your review as structured JSON (and ONLY JSON, no markdown fences or other text).\n\n")
|
||||
sb.WriteString("Output format:\n")
|
||||
sb.WriteString("{\n")
|
||||
sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
|
||||
sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
|
||||
sb.WriteString(" \"findings\": [\n")
|
||||
sb.WriteString(" {\n")
|
||||
sb.WriteString(" \"severity\": \"MAJOR\" or \"MINOR\" or \"NIT\",\n")
|
||||
sb.WriteString(" \"file\": \"path/to/file\",\n")
|
||||
sb.WriteString(" \"line\": <line number from the diff>,\n")
|
||||
sb.WriteString(" \"finding\": \"Description of the issue\"\n")
|
||||
sb.WriteString(" }\n")
|
||||
sb.WriteString(" ],\n")
|
||||
sb.WriteString(" \"recommendation\": \"Full recommendation text explaining your verdict\"\n")
|
||||
sb.WriteString("}\n\n")
|
||||
sb.WriteString("Rules:\n")
|
||||
sb.WriteString("- If there are any MAJOR findings → verdict must be REQUEST_CHANGES\n")
|
||||
sb.WriteString("- If there are no MAJOR findings → verdict should be APPROVE\n")
|
||||
sb.WriteString("- If CI has failed → verdict must be REQUEST_CHANGES with a finding noting the CI failure\n")
|
||||
sb.WriteString("- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\n")
|
||||
sb.WriteString(outputSchemaJSON)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(verdictRules)
|
||||
sb.WriteString("\n- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\n")
|
||||
sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n")
|
||||
sb.WriteString("- If the diff is empty or trivial (only formatting/whitespace), APPROVE with no findings.\n")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user