Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d50707332 |
@@ -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: ''
|
||||
|
||||
|
||||
@@ -380,32 +380,38 @@ Each persona posts independently with its own sentinel, so reviews don't interfe
|
||||
|
||||
### Custom Personas
|
||||
|
||||
Create a JSON file with your domain-specific review focus:
|
||||
Create a YAML file with your domain-specific review focus:
|
||||
|
||||
```json
|
||||
// .review/personas/trading.json
|
||||
{
|
||||
"name": "trading",
|
||||
"display_name": "Trading Domain Expert",
|
||||
"identity": "You are a trading systems expert reviewing code for correctness.\n\nYour expertise:\n- Order lifecycle and state machines\n- Fill handling and partial fills\n- Position tracking and P&L calculations\n- Event sourcing invariants",
|
||||
"focus": [
|
||||
"Order state machine correctness",
|
||||
"Fill handling edge cases (partial, overfill)",
|
||||
"Position and P&L calculation accuracy",
|
||||
"Event replay determinism",
|
||||
"Decimal precision for money"
|
||||
],
|
||||
"ignore": [
|
||||
"Code style",
|
||||
"General performance",
|
||||
"Documentation formatting"
|
||||
],
|
||||
"severity": {
|
||||
"major": "Bugs that cause incorrect positions, fills, or money calculations",
|
||||
"minor": "Edge cases that could cause issues under unusual conditions",
|
||||
"nit": "Clarity improvements for domain logic"
|
||||
}
|
||||
}
|
||||
```yaml
|
||||
# .review/personas/trading.yaml
|
||||
name: trading
|
||||
display_name: Trading Domain Expert
|
||||
|
||||
identity: |
|
||||
You are a trading systems expert reviewing code for correctness.
|
||||
|
||||
Your expertise:
|
||||
- Order lifecycle and state machines
|
||||
- Fill handling and partial fills
|
||||
- Position tracking and P&L calculations
|
||||
- Event sourcing invariants
|
||||
|
||||
focus:
|
||||
- Order state machine correctness
|
||||
- Fill handling edge cases (partial, overfill)
|
||||
- Position and P&L calculation accuracy
|
||||
- Event replay determinism
|
||||
- Decimal precision for money
|
||||
|
||||
ignore:
|
||||
- Code style
|
||||
- General performance
|
||||
- Documentation formatting
|
||||
|
||||
severity:
|
||||
major: Bugs that cause incorrect positions, fills, or money calculations
|
||||
minor: Edge cases that could cause issues under unusual conditions
|
||||
nit: Clarity improvements for domain logic
|
||||
```
|
||||
|
||||
Use it in CI:
|
||||
@@ -414,17 +420,18 @@ Use it in CI:
|
||||
- uses: rodin/review-bot/.gitea/actions/review@v1
|
||||
with:
|
||||
reviewer-name: trading
|
||||
persona-file: .review/personas/trading.json
|
||||
persona-file: .review/personas/trading.yaml
|
||||
...
|
||||
```
|
||||
|
||||
JSON format is also supported for backwards compatibility.
|
||||
|
||||
### Persona vs system-prompt-file
|
||||
|
||||
| Feature | `persona` / `persona-file` | `system-prompt-file` |
|
||||
|---------|---------------------------|----------------------|
|
||||
| Replaces base prompt | Yes | No (appends) |
|
||||
| Structured format | Yes (JSON) | No (freeform) |
|
||||
| Structured format | Yes (YAML/JSON) | No (freeform) |
|
||||
| Focus/ignore lists | Yes | Manual |
|
||||
| Severity calibration | Yes | Manual |
|
||||
| Header display name | Yes | No |
|
||||
|
||||
+3
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
module gitea.weiker.me/rodin/review-bot
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+34
-4
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+37
-35
@@ -5,34 +5,37 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed personas/*.json
|
||||
//go:embed personas/*.yaml
|
||||
var embeddedPersonas embed.FS
|
||||
|
||||
// Persona defines a specialized review role with focused expertise.
|
||||
type Persona struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ModelPref string `json:"model_preference,omitempty"`
|
||||
Identity string `json:"identity"`
|
||||
Focus []string `json:"focus"`
|
||||
Ignore []string `json:"ignore"`
|
||||
Severity Severity `json:"severity"`
|
||||
OutputFormat string `json:"output_format,omitempty"`
|
||||
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"`
|
||||
Minor string `json:"minor"`
|
||||
Nit string `json:"nit"`
|
||||
Major string `json:"major" yaml:"major"`
|
||||
Minor string `json:"minor" yaml:"minor"`
|
||||
Nit string `json:"nit" yaml:"nit"`
|
||||
}
|
||||
|
||||
// LoadPersona loads a persona from a JSON file path.
|
||||
// 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 {
|
||||
@@ -44,8 +47,8 @@ func LoadPersona(path string) (*Persona, error) {
|
||||
// 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 + ".json"
|
||||
data, err := embeddedPersonas.ReadFile("personas/" + filename) // embed.FS paths use forward slashes per io/fs spec
|
||||
filename := name + ".yaml"
|
||||
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 +57,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 {
|
||||
@@ -66,8 +68,10 @@ func ListBuiltinPersonas() []string {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".json") {
|
||||
names = append(names, strings.TrimSuffix(name, ".json"))
|
||||
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
|
||||
@@ -75,9 +79,20 @@ func ListBuiltinPersonas() []string {
|
||||
|
||||
func parsePersona(data []byte, source string) (*Persona, error) {
|
||||
var p Persona
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return nil, fmt.Errorf("parse persona %s: %w", source, err)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -97,16 +112,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:]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+63
-60
@@ -87,22 +87,26 @@ func TestListBuiltinPersonas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaFromJSONFile(t *testing.T) {
|
||||
func TestLoadPersonaFromYAMLFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
path := filepath.Join(dir, "test.yaml")
|
||||
|
||||
content := `{
|
||||
"name": "test",
|
||||
"display_name": "Test Persona",
|
||||
"identity": "You are a test persona.\nMulti-line identity works.",
|
||||
"focus": ["testing", "validation"],
|
||||
"ignore": ["nothing"],
|
||||
"severity": {
|
||||
"major": "Big problems",
|
||||
"minor": "Small problems",
|
||||
"nit": "Tiny problems"
|
||||
}
|
||||
}`
|
||||
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)
|
||||
@@ -127,25 +131,59 @@ func TestLoadPersonaFromJSONFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
json string
|
||||
yaml string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing name",
|
||||
json: `{"identity": "test"}`,
|
||||
yaml: "identity: test",
|
||||
wantErr: "name is required",
|
||||
},
|
||||
{
|
||||
name: "missing identity",
|
||||
json: `{"name": "test"}`,
|
||||
yaml: "name: test",
|
||||
wantErr: "identity is required",
|
||||
},
|
||||
{
|
||||
name: "display_name defaults to name",
|
||||
json: `{"name": "test", "identity": "test identity"}`,
|
||||
yaml: "name: test\nidentity: test identity",
|
||||
// No error expected - should succeed
|
||||
},
|
||||
}
|
||||
@@ -153,8 +191,8 @@ func TestLoadPersonaValidation(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
if err := os.WriteFile(path, []byte(tt.json), 0644); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -184,56 +222,21 @@ func TestLoadPersonaValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadPersonaFileNotFound(t *testing.T) {
|
||||
_, err := LoadPersona("/nonexistent/path/persona.json")
|
||||
_, err := LoadPersona("/nonexistent/path/persona.yaml")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaInvalidJSON(t *testing.T) {
|
||||
func TestLoadPersonaInvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(path, []byte("not valid json {"), 0644); err != nil {
|
||||
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 JSON")
|
||||
}
|
||||
}
|
||||
|
||||
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 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")
|
||||
t.Error("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"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,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"
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"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,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"
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "security",
|
||||
"display_name": "Security Specialist",
|
||||
"identity": "You are a security specialist reviewing code for vulnerabilities.\n\nYour expertise:\n- OWASP Top 10 vulnerabilities\n- Injection attacks (SQL, command, path traversal, template)\n- Authentication and authorization patterns\n- Secrets management and exposure risks\n- Race conditions with security implications\n- 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
+18
-26
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user