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
default: ''
persona-file:
description: 'Path to custom persona JSON file'
description: 'Path to persona JSON file with custom review focus'
required: false
default: ''
-3
View File
@@ -377,13 +377,11 @@ jobs:
Each persona posts independently with its own sentinel, so reviews don't interfere.
### Custom Personas
Create a JSON file with your domain-specific review focus:
```json
// .review/personas/trading.json
{
"name": "trading",
"display_name": "Trading Domain Expert",
@@ -418,7 +416,6 @@ Use it in CI:
...
```
### Persona vs 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 {
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 using filepath.Rel (more robust than HasPrefix)
rel, err := filepath.Rel(absWorkspace, fullPath)
if err != nil || strings.HasPrefix(rel, "..") {
// 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)
}
relResolved, err := filepath.Rel(absWorkspace, resolvedPath)
if err != nil || strings.HasPrefix(relResolved, "..") {
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
}
+7 -7
View File
@@ -6,8 +6,8 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"path/filepath"
"testing"
"gitea.weiker.me/rodin/review-bot/gitea"
@@ -103,13 +103,11 @@ func TestValidateWorkspacePath(t *testing.T) {
errMatch: "resolves outside workspace",
},
{
name: "absolute path normalized to workspace-relative",
name: "absolute path gets normalized to relative",
workspace: tmpDir,
path: "/etc/passwd",
wantErr: true,
// Go 1.21+ filepath.Join normalizes absolute paths: Join("/tmp/x", "/etc/passwd")
// becomes "/tmp/x/etc/passwd", which is within workspace but doesn't exist.
errMatch: "failed to resolve",
errMatch: "failed to resolve", // filepath.Join strips leading / making it <workspace>/etc/passwd which doesn't exist
},
{
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 {
r := gitea.Review{
ID: id,
@@ -165,6 +164,7 @@ func makeReview(id int64, login, state string, stale bool, body string) gitea.Re
return r
}
func TestBuildSupersededBody(t *testing.T) {
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
sentinel := "<!-- review-bot:sonnet -->"
@@ -734,8 +734,8 @@ func TestExtractSentinelName_EdgeCases(t *testing.T) {
{"<!-- review-bot:sonnet --> rest", "sonnet"},
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
{"no sentinel here", "unknown"},
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
}
for _, tc := range tests {
+34 -15
View File
@@ -1,9 +1,5 @@
# 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
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?
- **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)
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
```json
```yaml
# .review/personas/security.yaml
name: security
display_name: Security Specialist
@@ -81,7 +77,7 @@ output_format: |
### 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)
```
@@ -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.
## 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:
- Multi-line strings are cleaner (no escaping quotes in identity/focus text)
- Comments are supported for documentation
- More human-readable for complex persona definitions
YAML requires `gopkg.in/yaml.v3` or similar. To maintain zero dependencies, persona files will use JSON instead.
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.
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.
@@ -23,8 +55,6 @@ func GiteaEvent(verdict string) string {
}
// 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 {
@@ -37,7 +67,7 @@ func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName s
}
if headerName != "" {
title := CapitalizeFirst(headerName)
title := strings.ToUpper(headerName[:1]) + headerName[1:]
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
}
+4 -18
View File
@@ -5,8 +5,8 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
//go:embed personas/*.json
@@ -32,7 +32,7 @@ type Severity struct {
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) {
data, err := os.ReadFile(path)
if err != nil {
@@ -45,7 +45,7 @@ func LoadPersona(path string) (*Persona, error) {
// Returns an error if the persona doesn't exist.
func LoadBuiltinPersona(name string) (*Persona, error) {
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 {
available := ListBuiltinPersonas()
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.
// Returns an empty slice if the embedded directory cannot be read.
func ListBuiltinPersonas() []string {
entries, err := embeddedPersonas.ReadDir("personas")
if err != nil {
return []string{}
return nil
}
var names []string
for _, e := range entries {
@@ -97,16 +96,3 @@ func validatePersona(p *Persona, source string) error {
}
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")
}
// 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("CONTEXT:\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("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("{\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("- 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")
+15 -43
View File
@@ -3,7 +3,6 @@ package review
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -24,7 +23,7 @@ func TestLoadBuiltinPersona(t *testing.T) {
name: "architect persona",
personaName: "architect",
wantErr: false,
wantDisplay: "Software Architect",
wantDisplay: "Architecture Reviewer",
},
{
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()
path := filepath.Join(dir, "test.json")
content := `{
"name": "test",
"display_name": "Test Persona",
"identity": "You are a test persona.\nMulti-line identity works.",
"focus": ["testing", "validation"],
"identity": "You are a test persona.",
"focus": ["testing"],
"ignore": ["nothing"],
"severity": {
"major": "Big problems",
@@ -119,12 +119,6 @@ func TestLoadPersonaFromJSONFile(t *testing.T) {
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 TestLoadPersonaValidation(t *testing.T) {
@@ -164,7 +158,7 @@ func TestLoadPersonaValidation(t *testing.T) {
t.Errorf("expected error containing %q, got nil", tt.wantErr)
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)
}
return
@@ -193,7 +187,7 @@ func TestLoadPersonaFileNotFound(t *testing.T) {
func TestLoadPersonaInvalidJSON(t *testing.T) {
dir := t.TempDir()
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)
}
@@ -203,37 +197,15 @@ func TestLoadPersonaInvalidJSON(t *testing.T) {
}
}
func TestCapitalizeFirst(t *testing.T) {
tests := []struct {
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 contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func TestListBuiltinPersonasReturnsEmptySlice(t *testing.T) {
// ListBuiltinPersonas should return an empty slice (not nil) on error.
// We can't easily test the error case, but we can verify the success case
// returns a proper slice.
names := ListBuiltinPersonas()
if names == nil {
t.Error("ListBuiltinPersonas should return empty slice, not nil")
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+15 -16
View File
@@ -1,26 +1,25 @@
{
"name": "architect",
"display_name": "Software Architect",
"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",
"display_name": "Architecture Reviewer",
"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": [
"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"
"Design pattern violations or misapplications",
"Module boundary violations and improper coupling",
"API contract clarity and consistency",
"Error handling completeness and patterns",
"Concurrency safety and patterns",
"Testability and dependency injection",
"Separation of concerns"
],
"ignore": [
"Security vulnerabilities (security persona handles these)",
"Security vulnerabilities (handled by security persona)",
"Performance micro-optimizations",
"Code style and formatting",
"Documentation typos",
"Test implementation details"
"Minor style preferences",
"Documentation formatting"
],
"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"
"major": "Design issues that will cause maintenance burden or bugs: tight coupling, missing abstractions, broken contracts",
"minor": "Suboptimal patterns that could be improved: redundant code, unclear boundaries",
"nit": "Style suggestions that improve consistency but don't affect correctness"
}
}
+14 -16
View File
@@ -1,26 +1,24 @@
{
"name": "docs",
"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": [
"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"
"Missing or outdated API documentation",
"Misleading or incorrect code comments",
"Unclear error messages",
"Missing or incorrect examples",
"README accuracy and completeness",
"Public API ergonomics and naming"
],
"ignore": [
"Security vulnerabilities",
"Performance issues",
"Design patterns",
"Test coverage",
"Code style (unless it affects readability)"
"Implementation details (unless they affect the public API)",
"Performance",
"Security (handled by security persona)",
"Internal code organization"
],
"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"
"major": "Misleading documentation that will cause users to make mistakes",
"minor": "Missing documentation for public APIs",
"nit": "Minor wording improvements or formatting"
}
}
+18 -26
View File
@@ -7,28 +7,6 @@ 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.
@@ -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("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- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\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("- 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")