Compare commits

..

1 Commits

Author SHA1 Message Date
Rodin d33a45329c feat(persona): add role-based review personas (#51)
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 12s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 37s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m20s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m21s
Implement role-based review personas that provide specialized review focus:
- Security: vulnerabilities, auth, secrets, injection attacks
- Architect: design patterns, code organization, API contracts
- Docs: documentation quality, API clarity, error messages

Changes:
- Add persona loading from JSON files and embedded built-ins
- Add --persona and --persona-file CLI flags (mutually exclusive)
- Add BuildPersonaSystemPrompt for persona-specific prompts
- Add FormatMarkdownWithDisplay for persona display names
- Update action.yml with persona and persona-file inputs
- Add comprehensive tests for all new functionality
- Document personas in README with examples

The persona system replaces the generic 'You are an expert code reviewer'
prompt with domain-specific identity, focus areas, ignore list, and
severity calibration. This reduces redundancy between multiple reviewers
and catches domain-specific issues that generic reviewers miss.

Closes #51
2026-05-10 08:42:26 -07:00
4 changed files with 30 additions and 153 deletions
+3 -3
View File
@@ -71,9 +71,6 @@ inputs:
required: false required: false
default: 'true' default: 'true'
system-prompt-file: system-prompt-file:
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
required: false
default: ''
persona: persona:
description: 'Built-in persona name (security, architect, docs)' description: 'Built-in persona name (security, architect, docs)'
required: false required: false
@@ -82,6 +79,9 @@ inputs:
description: 'Path to persona JSON file with custom review focus' description: 'Path to persona JSON file with custom review focus'
required: false required: false
default: '' default: ''
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
required: false
default: ''
runs: runs:
using: 'composite' using: 'composite'
+26 -39
View File
@@ -93,6 +93,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Validate persona flags are mutually exclusive // Validate persona flags are mutually exclusive
if *personaName != "" && *personaFile != "" { if *personaName != "" && *personaFile != "" {
slog.Error("--persona and --persona-file are mutually exclusive") slog.Error("--persona and --persona-file are mutually exclusive")
@@ -110,12 +111,8 @@ func main() {
} }
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName) slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
} else if *personaFile != "" { } else if *personaFile != "" {
resolvedPath, err := validateWorkspacePath(*personaFile, "persona-file") var err error
if err != nil { persona, err = review.LoadPersona(*personaFile)
slog.Error("invalid persona-file path", "error", err)
os.Exit(1)
}
persona, err = review.LoadPersona(resolvedPath)
if err != nil { if err != nil {
slog.Error("failed to load persona file", "file", *personaFile, "error", err) slog.Error("failed to load persona file", "file", *personaFile, "error", err)
os.Exit(1) os.Exit(1)
@@ -233,14 +230,34 @@ func main() {
// Step 6b: Load additional system prompt if specified // Step 6b: Load additional system prompt if specified
additionalPrompt := "" additionalPrompt := ""
if *systemPromptFile != "" { if *systemPromptFile != "" {
resolvedPath, err := validateWorkspacePath(*systemPromptFile, "system-prompt-file") workspace := os.Getenv("GITHUB_WORKSPACE")
if workspace == "" {
workspace, _ = os.Getwd()
}
absWorkspace, err := filepath.Abs(workspace)
if err != nil { if err != nil {
slog.Error("invalid system-prompt-file path", "error", err) slog.Error("failed to resolve workspace path", "error", err)
os.Exit(1)
}
promptPath := filepath.Join(absWorkspace, *systemPromptFile)
promptPath = filepath.Clean(promptPath)
if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace {
slog.Error("system-prompt-file resolves outside workspace", "path", promptPath, "workspace", absWorkspace)
os.Exit(1)
}
// Resolve symlinks and re-validate to prevent symlink traversal
resolvedPath, err := filepath.EvalSymlinks(promptPath)
if err != nil {
slog.Error("failed to resolve system prompt file", "path", promptPath, "error", err)
os.Exit(1)
}
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
slog.Error("system-prompt-file symlink resolves outside workspace", "resolved", resolvedPath, "workspace", absWorkspace)
os.Exit(1) os.Exit(1)
} }
data, err := os.ReadFile(resolvedPath) data, err := os.ReadFile(resolvedPath)
if err != nil { if err != nil {
slog.Error("failed to read system prompt file", "path", *systemPromptFile, "error", err) slog.Error("failed to read system prompt file", "path", promptPath, "error", err)
os.Exit(1) os.Exit(1)
} }
additionalPrompt = string(data) additionalPrompt = string(data)
@@ -610,36 +627,6 @@ func validateReviewerName(name string) error {
return nil return nil
} }
// validateWorkspacePath ensures a file path is within the workspace and resolves
// symlinks to prevent traversal attacks. Returns the resolved absolute path or
// an error if the path is outside the workspace.
func validateWorkspacePath(path, pathName string) (string, error) {
workspace := os.Getenv("GITHUB_WORKSPACE")
if workspace == "" {
workspace, _ = os.Getwd()
}
absWorkspace, err := filepath.Abs(workspace)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
}
// Join and clean the path
fullPath := filepath.Join(absWorkspace, path)
fullPath = filepath.Clean(fullPath)
// Check path is within workspace
if !strings.HasPrefix(fullPath, absWorkspace+string(filepath.Separator)) && fullPath != absWorkspace {
return "", fmt.Errorf("%s resolves outside workspace: path=%s workspace=%s", pathName, fullPath, absWorkspace)
}
// Resolve symlinks and re-validate to prevent symlink traversal
resolvedPath, err := filepath.EvalSymlinks(fullPath)
if err != nil {
return "", fmt.Errorf("failed to resolve %s: %w", pathName, err)
}
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace)
}
return resolvedPath, nil
}
// buildSupersededBody creates the body for a superseded review: struck-through banner // buildSupersededBody creates the body for a superseded review: struck-through banner
// with collapsed original content and the commit it was evaluated against. // with collapsed original content and the commit it was evaluated against.
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string { func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
-108
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"path/filepath"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
@@ -46,113 +45,6 @@ func TestValidateReviewerName(t *testing.T) {
} }
} }
func TestValidateWorkspacePath(t *testing.T) {
// Create a temp directory as our workspace
tmpDir := t.TempDir()
// Create a valid file inside the workspace
validFile := filepath.Join(tmpDir, "valid.json")
if err := os.WriteFile(validFile, []byte("{}"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// Create a subdirectory with a file
subDir := filepath.Join(tmpDir, "subdir")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
nestedFile := filepath.Join(subDir, "nested.json")
if err := os.WriteFile(nestedFile, []byte("{}"), 0644); err != nil {
t.Fatalf("failed to create nested file: %v", err)
}
// Create a symlink pointing outside the workspace
symlinkPath := filepath.Join(tmpDir, "evil-symlink.json")
if err := os.Symlink("/etc/passwd", symlinkPath); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Save and restore GITHUB_WORKSPACE
origWorkspace := os.Getenv("GITHUB_WORKSPACE")
defer os.Setenv("GITHUB_WORKSPACE", origWorkspace)
tests := []struct {
name string
workspace string
path string
wantErr bool
errMatch string
}{
{
name: "valid relative path",
workspace: tmpDir,
path: "valid.json",
wantErr: false,
},
{
name: "valid nested path",
workspace: tmpDir,
path: "subdir/nested.json",
wantErr: false,
},
{
name: "path traversal attempt",
workspace: tmpDir,
path: "../../../etc/passwd",
wantErr: true,
errMatch: "resolves outside workspace",
},
{
name: "absolute path gets normalized to relative",
workspace: tmpDir,
path: "/etc/passwd",
wantErr: true,
errMatch: "failed to resolve", // filepath.Join strips leading / making it <workspace>/etc/passwd which doesn't exist
},
{
name: "nonexistent file",
workspace: tmpDir,
path: "nonexistent.json",
wantErr: true,
errMatch: "failed to resolve",
},
{
name: "symlink escaping workspace",
workspace: tmpDir,
path: "evil-symlink.json",
wantErr: true,
errMatch: "symlink resolves outside workspace",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
os.Setenv("GITHUB_WORKSPACE", tc.workspace)
resolved, err := validateWorkspacePath(tc.path, "test-file")
if tc.wantErr {
if err == nil {
t.Errorf("expected error for %q, got nil", tc.path)
} else if tc.errMatch != "" && !strings.Contains(err.Error(), tc.errMatch) {
t.Errorf("error %q should contain %q", err.Error(), tc.errMatch)
}
} else {
if err != nil {
t.Errorf("expected no error for %q, got %v", tc.path, err)
}
if resolved == "" {
t.Error("expected non-empty resolved path")
}
// Verify resolved path is within workspace
if !strings.HasPrefix(resolved, tc.workspace) {
t.Errorf("resolved path %q not within workspace %q", resolved, tc.workspace)
}
}
})
}
}
func makeReview(id int64, login, state string, stale bool, body string) gitea.Review { func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
r := gitea.Review{ r := gitea.Review{
ID: id, ID: id,
+1 -3
View File
@@ -92,9 +92,7 @@ func BuildPersonaSystemPrompt(p *Persona) string {
} }
// BuildSystemPromptWithPersona constructs the full system prompt, using either // BuildSystemPromptWithPersona constructs the full system prompt, using either
// a persona or the default generic prompt. This is a convenience wrapper that // a persona or the default generic prompt.
// 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 { func BuildSystemPromptWithPersona(persona *Persona, conventions, patterns string) string {
var base string var base string
if persona != nil { if persona != nil {