Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83441bfbac |
@@ -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: ''
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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:]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user