Compare commits

..

1 Commits

Author SHA1 Message Date
Rodin 83441bfbac feat(persona): add role-based review personas
PR Ready Gate / clear-labels (pull_request) Successful in 2s
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 1m0s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m12s
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
2026-05-10 08:47:51 -07:00
12 changed files with 164 additions and 164 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ inputs:
required: false required: false
default: '' default: ''
persona-file: persona-file:
description: 'Path to custom persona JSON file' description: 'Path to persona JSON file with custom review focus'
required: false required: false
default: '' default: ''
-3
View File
@@ -377,13 +377,11 @@ jobs:
Each persona posts independently with its own sentinel, so reviews don't interfere. Each persona posts independently with its own sentinel, so reviews don't interfere.
### Custom Personas ### Custom Personas
Create a JSON file with your domain-specific review focus: Create a JSON file with your domain-specific review focus:
```json ```json
// .review/personas/trading.json
{ {
"name": "trading", "name": "trading",
"display_name": "Trading Domain Expert", "display_name": "Trading Domain Expert",
@@ -418,7 +416,6 @@ Use it in CI:
... ...
``` ```
### Persona vs system-prompt-file ### Persona vs system-prompt-file
| Feature | `persona` / `persona-file` | `system-prompt-file` | | Feature | `persona` / `persona-file` | `system-prompt-file` |
+3 -10
View File
@@ -622,28 +622,21 @@ func validateWorkspacePath(path, pathName string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err) return "", fmt.Errorf("failed to resolve workspace path: %w", err)
} }
// Join and clean the path // Join and clean the path
fullPath := filepath.Join(absWorkspace, path) fullPath := filepath.Join(absWorkspace, path)
fullPath = filepath.Clean(fullPath) fullPath = filepath.Clean(fullPath)
// Check path is within workspace
// Check path is within workspace using filepath.Rel (more robust than HasPrefix) if !strings.HasPrefix(fullPath, absWorkspace+string(filepath.Separator)) && fullPath != absWorkspace {
rel, err := filepath.Rel(absWorkspace, fullPath)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("%s resolves outside workspace: path=%s workspace=%s", pathName, 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 // Resolve symlinks and re-validate to prevent symlink traversal
resolvedPath, err := filepath.EvalSymlinks(fullPath) resolvedPath, err := filepath.EvalSymlinks(fullPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve %s: %w", pathName, err) return "", fmt.Errorf("failed to resolve %s: %w", pathName, err)
} }
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
relResolved, err := filepath.Rel(absWorkspace, resolvedPath)
if err != nil || strings.HasPrefix(relResolved, "..") {
return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace) return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace)
} }
return resolvedPath, nil return resolvedPath, nil
} }
+7 -7
View File
@@ -6,8 +6,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"path/filepath"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
@@ -103,13 +103,11 @@ func TestValidateWorkspacePath(t *testing.T) {
errMatch: "resolves outside workspace", errMatch: "resolves outside workspace",
}, },
{ {
name: "absolute path normalized to workspace-relative", name: "absolute path gets normalized to relative",
workspace: tmpDir, workspace: tmpDir,
path: "/etc/passwd", path: "/etc/passwd",
wantErr: true, wantErr: true,
// Go 1.21+ filepath.Join normalizes absolute paths: Join("/tmp/x", "/etc/passwd") errMatch: "failed to resolve", // filepath.Join strips leading / making it <workspace>/etc/passwd which doesn't exist
// becomes "/tmp/x/etc/passwd", which is within workspace but doesn't exist.
errMatch: "failed to resolve",
}, },
{ {
name: "nonexistent file", name: "nonexistent file",
@@ -154,6 +152,7 @@ func TestValidateWorkspacePath(t *testing.T) {
} }
} }
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,
@@ -165,6 +164,7 @@ func makeReview(id int64, login, state string, stale bool, body string) gitea.Re
return r return r
} }
func TestBuildSupersededBody(t *testing.T) { func TestBuildSupersededBody(t *testing.T) {
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->" original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
sentinel := "<!-- review-bot:sonnet -->" sentinel := "<!-- review-bot:sonnet -->"
@@ -734,8 +734,8 @@ func TestExtractSentinelName_EdgeCases(t *testing.T) {
{"<!-- review-bot:sonnet --> rest", "sonnet"}, {"<!-- review-bot:sonnet --> rest", "sonnet"},
{"<!-- review-bot:gpt-review --> rest", "gpt-review"}, {"<!-- review-bot:gpt-review --> rest", "gpt-review"},
{"no sentinel here", "unknown"}, {"no sentinel here", "unknown"},
{"<!-- review-bot:", "unknown"}, // prefix but no suffix {"<!-- review-bot:", "unknown"}, // prefix but no suffix
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text {"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
} }
for _, tc := range tests { for _, tc := range tests {
+34 -15
View File
@@ -1,9 +1,5 @@
# Design: Role-based Review Personas (Issue #51) # Design: Role-based Review Personas (Issue #51)
> **Note:** This design was revised during implementation to use JSON instead of YAML
> to maintain the repository's zero-external-dependencies convention. All persona
> files use JSON format. See "Design Revision" section at the end for details.
## Problem ## Problem
Current review-bot performs generic code review. Every reviewer (regardless of `reviewer-name`) uses the same base prompt and evaluates the same concerns. This leads to: Current review-bot performs generic code review. Every reviewer (regardless of `reviewer-name`) uses the same base prompt and evaluates the same concerns. This leads to:
@@ -31,14 +27,14 @@ A persona is a named review role with:
- **Scope boundaries** — What do I explicitly NOT comment on? - **Scope boundaries** — What do I explicitly NOT comment on?
- **Severity calibration** — What counts as MAJOR/MINOR/NIT for MY domain? - **Severity calibration** — What counts as MAJOR/MINOR/NIT for MY domain?
Personas are defined in JSON files that can live: Personas are defined in YAML files that can live:
1. In the pattern repos (shared across projects) 1. In the pattern repos (shared across projects)
2. In the target repo (project-specific personas) 2. In the target repo (project-specific personas)
3. Inline via a new `--persona-file` flag (JSON format) 3. Inline via a new `--persona-file` flag
### 2. Persona File Format ### 2. Persona File Format
```json ```yaml
# .review/personas/security.yaml # .review/personas/security.yaml
name: security name: security
display_name: Security Specialist display_name: Security Specialist
@@ -81,7 +77,7 @@ output_format: |
### 3. New CLI Flags ### 3. New CLI Flags
``` ```
--persona-file PATH Path to persona JSON file (local or in repo) --persona-file PATH Path to persona YAML file (local or in repo)
--persona NAME Built-in persona name (security, architect, domain) --persona NAME Built-in persona name (security, architect, domain)
``` ```
@@ -322,13 +318,36 @@ Design says header shows "persona display name" but sentinel uses "reviewer-name
When persona is used, `display_name` takes precedence for the header title, but `reviewer-name` (CLI flag) is still used for the sentinel. When persona is used, `display_name` takes precedence for the header title, but `reviewer-name` (CLI flag) is still used for the sentinel.
## Design Revision: YAML with gopkg.in/yaml.v3 ## Design Revision: JSON Instead of YAML
**Decision:** Add `gopkg.in/yaml.v3` as a dependency. **Reason:** Project convention is "Go standard library only — no external dependencies."
YAML is preferred over JSON for persona files because: YAML requires `gopkg.in/yaml.v3` or similar. To maintain zero dependencies, persona files will use JSON instead.
- Multi-line strings are cleaner (no escaping quotes in identity/focus text)
- Comments are supported for documentation
- More human-readable for complex persona definitions
The implementation supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) for backwards compatibility, with YAML as the default for built-in personas. ### Updated Persona File Format
```json
{
"name": "security",
"display_name": "Security Specialist",
"model_preference": "opus",
"identity": "You are a security specialist reviewing code for vulnerabilities.\nYour expertise: OWASP Top 10, injection attacks, auth/authz, secrets management.",
"focus": [
"Injection attacks (SQL, command, path traversal, template)",
"Authentication and authorization gaps",
"Secrets exposure (hardcoded credentials, tokens in logs)"
],
"ignore": [
"Code style and naming conventions",
"Performance (unless security-related)",
"Documentation"
],
"severity": {
"major": "Privilege escalation, information disclosure, DoS",
"minor": "Missing rate limiting, verbose errors",
"nit": "Theoretical risk with low exploitability"
}
}
```
This maintains all the same fields but uses JSON encoding, which Go handles natively via `encoding/json`.
+34 -4
View File
@@ -7,7 +7,39 @@ import (
// FormatMarkdown formats a ReviewResult into the markdown body for a Gitea review. // FormatMarkdown formats a ReviewResult into the markdown body for a Gitea review.
func FormatMarkdown(result *ReviewResult, reviewerName string) string { func FormatMarkdown(result *ReviewResult, reviewerName string) string {
return FormatMarkdownWithDisplay(result, reviewerName, reviewerName) var sb strings.Builder
if reviewerName != "" {
title := strings.ToUpper(reviewerName[:1]) + reviewerName[1:]
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
}
sb.WriteString("## Summary\n\n")
sb.WriteString(result.Summary)
sb.WriteString("\n\n")
if len(result.Findings) > 0 {
sb.WriteString("## Findings\n\n")
sb.WriteString("| # | Severity | File | Line | Finding |\n")
sb.WriteString("|---|----------|------|------|--------|\n")
for i, f := range result.Findings {
sb.WriteString(fmt.Sprintf("| %d | [%s] | `%s` | %d | %s |\n",
i+1, f.Severity, f.File, f.Line, f.Finding))
}
sb.WriteString("\n")
}
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))
// Hidden sentinel for identifying this bot's reviews during cleanup
sb.WriteString(fmt.Sprintf("\n<!-- review-bot:%s -->\n", reviewerName))
}
return sb.String()
} }
// GiteaEvent converts the verdict to the Gitea API event string. // GiteaEvent converts the verdict to the Gitea API event string.
@@ -23,8 +55,6 @@ func GiteaEvent(verdict string) string {
} }
// FormatMarkdownWithDisplay formats a ReviewResult with separate display name and sentinel name. // 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. // displayName is used for the header title, sentinelName is used for the cleanup sentinel.
// If displayName is empty, sentinelName is used for both. // If displayName is empty, sentinelName is used for both.
func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName string) string { func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName string) string {
@@ -37,7 +67,7 @@ func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName s
} }
if headerName != "" { if headerName != "" {
title := CapitalizeFirst(headerName) title := strings.ToUpper(headerName[:1]) + headerName[1:]
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title)) sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
} }
+4 -18
View File
@@ -5,8 +5,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"unicode/utf8"
) )
//go:embed personas/*.json //go:embed personas/*.json
@@ -32,7 +32,7 @@ type Severity struct {
Nit string `json:"nit"` Nit string `json:"nit"`
} }
// LoadPersona loads a persona from a JSON file path. // LoadPersona loads a persona from a file path.
func LoadPersona(path string) (*Persona, error) { func LoadPersona(path string) (*Persona, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -45,7 +45,7 @@ func LoadPersona(path string) (*Persona, error) {
// Returns an error if the persona doesn't exist. // Returns an error if the persona doesn't exist.
func LoadBuiltinPersona(name string) (*Persona, error) { func LoadBuiltinPersona(name string) (*Persona, error) {
filename := name + ".json" filename := name + ".json"
data, err := embeddedPersonas.ReadFile("personas/" + filename) // embed.FS paths use forward slashes per io/fs spec data, err := embeddedPersonas.ReadFile(filepath.Join("personas", filename))
if err != nil { if err != nil {
available := ListBuiltinPersonas() available := ListBuiltinPersonas()
return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", ")) return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", "))
@@ -54,11 +54,10 @@ func LoadBuiltinPersona(name string) (*Persona, error) {
} }
// ListBuiltinPersonas returns the names of all built-in personas. // ListBuiltinPersonas returns the names of all built-in personas.
// Returns an empty slice if the embedded directory cannot be read.
func ListBuiltinPersonas() []string { func ListBuiltinPersonas() []string {
entries, err := embeddedPersonas.ReadDir("personas") entries, err := embeddedPersonas.ReadDir("personas")
if err != nil { if err != nil {
return []string{} return nil
} }
var names []string var names []string
for _, e := range entries { for _, e := range entries {
@@ -97,16 +96,3 @@ func validatePersona(p *Persona, source string) error {
} }
return nil return nil
} }
// CapitalizeFirst capitalizes the first rune of a string in a Unicode-safe way.
// Returns the original string if it's empty.
func CapitalizeFirst(s string) string {
if s == "" {
return s
}
r, size := utf8.DecodeRuneInString(s)
if r == utf8.RuneError {
return s
}
return strings.ToUpper(string(r)) + s[size:]
}
+19 -5
View File
@@ -50,7 +50,7 @@ func BuildPersonaSystemPrompt(p *Persona) string {
sb.WriteString("\n") sb.WriteString("\n")
} }
// Output format instructions (shared schema from prompt.go) // Output format instructions (same as base, but with persona context)
sb.WriteString("## Review Instructions\n\n") sb.WriteString("## Review Instructions\n\n")
sb.WriteString("CONTEXT:\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("- You will receive the full content of modified files for reference, followed by the diff showing what changed.\n")
@@ -61,10 +61,24 @@ func BuildPersonaSystemPrompt(p *Persona) 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("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("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("Output format:\n")
sb.WriteString(outputSchemaJSON) sb.WriteString("{\n")
sb.WriteString("\n\n") sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
sb.WriteString(verdictRules) sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
sb.WriteString("\n- Only report findings within your focus areas. Ignore everything else.\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("- 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("- 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") sb.WriteString("- If the diff has no changes relevant to your focus areas, APPROVE with no findings.\n")
+15 -43
View File
@@ -3,7 +3,6 @@ package review
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -24,7 +23,7 @@ func TestLoadBuiltinPersona(t *testing.T) {
name: "architect persona", name: "architect persona",
personaName: "architect", personaName: "architect",
wantErr: false, wantErr: false,
wantDisplay: "Software Architect", wantDisplay: "Architecture Reviewer",
}, },
{ {
name: "docs persona", name: "docs persona",
@@ -87,15 +86,16 @@ func TestListBuiltinPersonas(t *testing.T) {
} }
} }
func TestLoadPersonaFromJSONFile(t *testing.T) { func TestLoadPersonaFromFile(t *testing.T) {
// Create a temp persona file
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "test.json") path := filepath.Join(dir, "test.json")
content := `{ content := `{
"name": "test", "name": "test",
"display_name": "Test Persona", "display_name": "Test Persona",
"identity": "You are a test persona.\nMulti-line identity works.", "identity": "You are a test persona.",
"focus": ["testing", "validation"], "focus": ["testing"],
"ignore": ["nothing"], "ignore": ["nothing"],
"severity": { "severity": {
"major": "Big problems", "major": "Big problems",
@@ -119,12 +119,6 @@ func TestLoadPersonaFromJSONFile(t *testing.T) {
if p.DisplayName != "Test Persona" { if p.DisplayName != "Test Persona" {
t.Errorf("DisplayName = %q, want %q", 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 TestLoadPersonaValidation(t *testing.T) { func TestLoadPersonaValidation(t *testing.T) {
@@ -164,7 +158,7 @@ func TestLoadPersonaValidation(t *testing.T) {
t.Errorf("expected error containing %q, got nil", tt.wantErr) t.Errorf("expected error containing %q, got nil", tt.wantErr)
return return
} }
if !strings.Contains(err.Error(), tt.wantErr) { if !contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr) t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
} }
return return
@@ -193,7 +187,7 @@ func TestLoadPersonaFileNotFound(t *testing.T) {
func TestLoadPersonaInvalidJSON(t *testing.T) { func TestLoadPersonaInvalidJSON(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "invalid.json") path := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(path, []byte("not valid json {"), 0644); err != nil { if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err) t.Fatalf("failed to write test file: %v", err)
} }
@@ -203,37 +197,15 @@ func TestLoadPersonaInvalidJSON(t *testing.T) {
} }
} }
func TestCapitalizeFirst(t *testing.T) { func contains(s, substr string) bool {
tests := []struct { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
input string
want string
}{
{"hello", "Hello"},
{"Hello", "Hello"},
{"HELLO", "HELLO"},
{"a", "A"},
{"", ""},
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
{"über", "Über"}, // German umlaut
{"élève", "Élève"}, // French accent
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := CapitalizeFirst(tt.input)
if got != tt.want {
t.Errorf("CapitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
} }
func TestListBuiltinPersonasReturnsEmptySlice(t *testing.T) { func containsHelper(s, substr string) bool {
// ListBuiltinPersonas should return an empty slice (not nil) on error. for i := 0; i <= len(s)-len(substr); i++ {
// We can't easily test the error case, but we can verify the success case if s[i:i+len(substr)] == substr {
// returns a proper slice. return true
names := ListBuiltinPersonas() }
if names == nil {
t.Error("ListBuiltinPersonas should return empty slice, not nil")
} }
return false
} }
+15 -16
View File
@@ -1,26 +1,25 @@
{ {
"name": "architect", "name": "architect",
"display_name": "Software Architect", "display_name": "Architecture Reviewer",
"identity": "You are a software architect reviewing code for design quality.\n\nYour expertise:\n- Design patterns and anti-patterns\n- Code organization and module boundaries\n- API design and contracts\n- Testability and dependency injection\n- Consistency with existing architecture\n- Technical debt identification", "identity": "You are an architecture reviewer focused on design patterns, code organization, and maintainability.\n\nYour expertise:\n- Design patterns and their appropriate application\n- Code organization and module boundaries\n- API design and contracts\n- Error handling patterns\n- Concurrency patterns and safety\n- Testing patterns and testability",
"focus": [ "focus": [
"Design pattern violations or misuse", "Design pattern violations or misapplications",
"Module boundary violations (inappropriate coupling)", "Module boundary violations and improper coupling",
"API design issues (unclear contracts, leaky abstractions)", "API contract clarity and consistency",
"Testability problems (hidden dependencies, god objects)", "Error handling completeness and patterns",
"Inconsistency with existing codebase patterns", "Concurrency safety and patterns",
"Unnecessary complexity or over-engineering", "Testability and dependency injection",
"Missing abstractions or premature abstraction" "Separation of concerns"
], ],
"ignore": [ "ignore": [
"Security vulnerabilities (security persona handles these)", "Security vulnerabilities (handled by security persona)",
"Performance micro-optimizations", "Performance micro-optimizations",
"Code style and formatting", "Minor style preferences",
"Documentation typos", "Documentation formatting"
"Test implementation details"
], ],
"severity": { "severity": {
"major": "Architectural violations that will cause maintenance problems or make the codebase harder to evolve", "major": "Design issues that will cause maintenance burden or bugs: tight coupling, missing abstractions, broken contracts",
"minor": "Design issues that reduce clarity or testability but don't block progress", "minor": "Suboptimal patterns that could be improved: redundant code, unclear boundaries",
"nit": "Minor pattern deviations or style preferences" "nit": "Style suggestions that improve consistency but don't affect correctness"
} }
} }
+14 -16
View File
@@ -1,26 +1,24 @@
{ {
"name": "docs", "name": "docs",
"display_name": "Documentation Reviewer", "display_name": "Documentation Reviewer",
"identity": "You are a documentation specialist reviewing code for clarity and documentation quality.\n\nYour expertise:\n- API documentation and examples\n- Code comments and their accuracy\n- Error message clarity\n- README and guide quality\n- Naming clarity and self-documenting code", "identity": "You are a documentation reviewer focused on API clarity, code comments, and user-facing documentation.\n\nYour expertise:\n- API documentation completeness\n- Code comment quality and accuracy\n- README and user guide clarity\n- Example code correctness\n- Error message helpfulness",
"focus": [ "focus": [
"Missing or outdated documentation", "Missing or outdated API documentation",
"Unclear or misleading comments", "Misleading or incorrect code comments",
"Poor error messages (cryptic, unhelpful, missing context)", "Unclear error messages",
"Confusing naming (functions, variables, types)", "Missing or incorrect examples",
"Missing examples for complex APIs", "README accuracy and completeness",
"Inconsistent terminology", "Public API ergonomics and naming"
"Documentation that contradicts the code"
], ],
"ignore": [ "ignore": [
"Security vulnerabilities", "Implementation details (unless they affect the public API)",
"Performance issues", "Performance",
"Design patterns", "Security (handled by security persona)",
"Test coverage", "Internal code organization"
"Code style (unless it affects readability)"
], ],
"severity": { "severity": {
"major": "Documentation that actively misleads or missing docs for critical functionality", "major": "Misleading documentation that will cause users to make mistakes",
"minor": "Unclear documentation or poor error messages that will confuse users", "minor": "Missing documentation for public APIs",
"nit": "Minor clarity improvements or typo fixes" "nit": "Minor wording improvements or formatting"
} }
} }
+18 -26
View File
@@ -7,28 +7,6 @@ import (
"strings" "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 // BuildSystemBase returns the core system prompt instructions without
// patterns or conventions. Used by the budget package to separate // patterns or conventions. Used by the budget package to separate
// trimmable from non-trimmable content. // trimmable from non-trimmable content.
@@ -45,10 +23,24 @@ 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("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("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("Output format:\n")
sb.WriteString(outputSchemaJSON) sb.WriteString("{\n")
sb.WriteString("\n\n") sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
sb.WriteString(verdictRules) sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
sb.WriteString("\n- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\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("- Line numbers should reference the new file line numbers from the diff headers.\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") sb.WriteString("- If the diff is empty or trivial (only formatting/whitespace), APPROVE with no findings.\n")