Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd67742f9 | |||
| 57e62a345f | |||
| 44d6fa9d57 | |||
| 4ea41e164e |
@@ -71,15 +71,15 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: 'true'
|
default: 'true'
|
||||||
system-prompt-file:
|
system-prompt-file:
|
||||||
|
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
persona:
|
persona:
|
||||||
description: 'Built-in persona name (security, architect, docs)'
|
description: 'Built-in persona name (security, architect, docs)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
persona-file:
|
persona-file:
|
||||||
description: 'Path to persona JSON file with custom review focus'
|
description: 'Path to custom persona JSON file'
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
|
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,22 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
clear-labels:
|
clear-labels:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'self-reviewed')
|
# Always run - curl commands are safe if labels don't exist
|
||||||
steps:
|
steps:
|
||||||
- name: Remove self-reviewed label, reassign to author
|
- name: Remove ready and self-reviewed labels, reassign to author
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.RODIN_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RODIN_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||||
AUTHOR=${{ github.event.pull_request.user.login }}
|
AUTHOR=${{ github.event.pull_request.user.login }}
|
||||||
|
READY_LABEL_ID=38
|
||||||
SELF_REVIEWED_LABEL_ID=37
|
SELF_REVIEWED_LABEL_ID=37
|
||||||
|
|
||||||
|
# Remove ready label if present
|
||||||
|
curl -sS -X DELETE \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/issues/${PR_NUMBER}/labels/${READY_LABEL_ID}" || true
|
||||||
|
|
||||||
# Remove self-reviewed label if present
|
# Remove self-reviewed label if present
|
||||||
curl -sS -X DELETE \
|
curl -sS -X DELETE \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
@@ -29,4 +35,4 @@ jobs:
|
|||||||
-d "{\"assignees\": [\"${AUTHOR}\"]}" \
|
-d "{\"assignees\": [\"${AUTHOR}\"]}" \
|
||||||
"https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/pulls/${PR_NUMBER}"
|
"https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/pulls/${PR_NUMBER}"
|
||||||
|
|
||||||
echo "Cleared self-reviewed label and reassigned PR #${PR_NUMBER} to ${AUTHOR}"
|
echo "Cleared ready/self-reviewed labels and reassigned PR #${PR_NUMBER} to ${AUTHOR}"
|
||||||
|
|||||||
@@ -377,11 +377,13 @@ 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",
|
||||||
@@ -416,6 +418,7 @@ 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` |
|
||||||
|
|||||||
+46
-26
@@ -93,7 +93,6 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Validate persona flags are mutually exclusive
|
// Validate persona flags are mutually exclusive
|
||||||
if *personaName != "" && *personaFile != "" {
|
if *personaName != "" && *personaFile != "" {
|
||||||
slog.Error("--persona and --persona-file are mutually exclusive")
|
slog.Error("--persona and --persona-file are mutually exclusive")
|
||||||
@@ -111,8 +110,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
|
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
|
||||||
} else if *personaFile != "" {
|
} else if *personaFile != "" {
|
||||||
var err error
|
resolvedPath, err := validateWorkspacePath(*personaFile, "persona-file")
|
||||||
persona, err = review.LoadPersona(*personaFile)
|
if err != nil {
|
||||||
|
slog.Error("invalid persona-file path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
persona, err = review.LoadPersona(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load persona file", "file", *personaFile, "error", err)
|
slog.Error("failed to load persona file", "file", *personaFile, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -230,34 +233,14 @@ func main() {
|
|||||||
// Step 6b: Load additional system prompt if specified
|
// Step 6b: Load additional system prompt if specified
|
||||||
additionalPrompt := ""
|
additionalPrompt := ""
|
||||||
if *systemPromptFile != "" {
|
if *systemPromptFile != "" {
|
||||||
workspace := os.Getenv("GITHUB_WORKSPACE")
|
resolvedPath, err := validateWorkspacePath(*systemPromptFile, "system-prompt-file")
|
||||||
if workspace == "" {
|
|
||||||
workspace, _ = os.Getwd()
|
|
||||||
}
|
|
||||||
absWorkspace, err := filepath.Abs(workspace)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to resolve workspace path", "error", err)
|
slog.Error("invalid system-prompt-file path", "error", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
promptPath := filepath.Join(absWorkspace, *systemPromptFile)
|
|
||||||
promptPath = filepath.Clean(promptPath)
|
|
||||||
if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace {
|
|
||||||
slog.Error("system-prompt-file resolves outside workspace", "path", promptPath, "workspace", absWorkspace)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
// Resolve symlinks and re-validate to prevent symlink traversal
|
|
||||||
resolvedPath, err := filepath.EvalSymlinks(promptPath)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to resolve system prompt file", "path", promptPath, "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
|
|
||||||
slog.Error("system-prompt-file symlink resolves outside workspace", "resolved", resolvedPath, "workspace", absWorkspace)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(resolvedPath)
|
data, err := os.ReadFile(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to read system prompt file", "path", promptPath, "error", err)
|
slog.Error("failed to read system prompt file", "path", *systemPromptFile, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
additionalPrompt = string(data)
|
additionalPrompt = string(data)
|
||||||
@@ -627,6 +610,43 @@ func validateReviewerName(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateWorkspacePath ensures a file path is within the workspace and resolves
|
||||||
|
// symlinks to prevent traversal attacks. Returns the resolved absolute path or
|
||||||
|
// an error if the path is outside the workspace.
|
||||||
|
func validateWorkspacePath(path, pathName string) (string, error) {
|
||||||
|
workspace := os.Getenv("GITHUB_WORKSPACE")
|
||||||
|
if workspace == "" {
|
||||||
|
workspace, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
absWorkspace, err := filepath.Abs(workspace)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join and clean the path
|
||||||
|
fullPath := filepath.Join(absWorkspace, path)
|
||||||
|
fullPath = filepath.Clean(fullPath)
|
||||||
|
|
||||||
|
// Check path is within workspace using filepath.Rel (more robust than HasPrefix)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, "..") {
|
||||||
|
return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildSupersededBody creates the body for a superseded review: struck-through banner
|
// buildSupersededBody creates the body for a superseded review: struck-through banner
|
||||||
// with collapsed original content and the commit it was evaluated against.
|
// with collapsed original content and the commit it was evaluated against.
|
||||||
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
|
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
|
||||||
|
|||||||
+111
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -45,6 +46,114 @@ func TestValidateReviewerName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateWorkspacePath(t *testing.T) {
|
||||||
|
// Create a temp directory as our workspace
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a valid file inside the workspace
|
||||||
|
validFile := filepath.Join(tmpDir, "valid.json")
|
||||||
|
if err := os.WriteFile(validFile, []byte("{}"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a subdirectory with a file
|
||||||
|
subDir := filepath.Join(tmpDir, "subdir")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
nestedFile := filepath.Join(subDir, "nested.json")
|
||||||
|
if err := os.WriteFile(nestedFile, []byte("{}"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create nested file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a symlink pointing outside the workspace
|
||||||
|
symlinkPath := filepath.Join(tmpDir, "evil-symlink.json")
|
||||||
|
if err := os.Symlink("/etc/passwd", symlinkPath); err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and restore GITHUB_WORKSPACE
|
||||||
|
origWorkspace := os.Getenv("GITHUB_WORKSPACE")
|
||||||
|
defer os.Setenv("GITHUB_WORKSPACE", origWorkspace)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
workspace string
|
||||||
|
path string
|
||||||
|
wantErr bool
|
||||||
|
errMatch string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid relative path",
|
||||||
|
workspace: tmpDir,
|
||||||
|
path: "valid.json",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid nested path",
|
||||||
|
workspace: tmpDir,
|
||||||
|
path: "subdir/nested.json",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path traversal attempt",
|
||||||
|
workspace: tmpDir,
|
||||||
|
path: "../../../etc/passwd",
|
||||||
|
wantErr: true,
|
||||||
|
errMatch: "resolves outside workspace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path normalized to workspace-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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonexistent file",
|
||||||
|
workspace: tmpDir,
|
||||||
|
path: "nonexistent.json",
|
||||||
|
wantErr: true,
|
||||||
|
errMatch: "failed to resolve",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "symlink escaping workspace",
|
||||||
|
workspace: tmpDir,
|
||||||
|
path: "evil-symlink.json",
|
||||||
|
wantErr: true,
|
||||||
|
errMatch: "symlink resolves outside workspace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
os.Setenv("GITHUB_WORKSPACE", tc.workspace)
|
||||||
|
resolved, err := validateWorkspacePath(tc.path, "test-file")
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for %q, got nil", tc.path)
|
||||||
|
} else if tc.errMatch != "" && !strings.Contains(err.Error(), tc.errMatch) {
|
||||||
|
t.Errorf("error %q should contain %q", err.Error(), tc.errMatch)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error for %q, got %v", tc.path, err)
|
||||||
|
}
|
||||||
|
if resolved == "" {
|
||||||
|
t.Error("expected non-empty resolved path")
|
||||||
|
}
|
||||||
|
// Verify resolved path is within workspace
|
||||||
|
if !strings.HasPrefix(resolved, tc.workspace) {
|
||||||
|
t.Errorf("resolved path %q not within workspace %q", resolved, tc.workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
|
func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
|
||||||
r := gitea.Review{
|
r := gitea.Review{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -56,7 +165,6 @@ 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 -->"
|
||||||
@@ -626,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 {
|
||||||
|
|||||||
+15
-34
@@ -1,5 +1,9 @@
|
|||||||
# 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:
|
||||||
@@ -27,14 +31,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 YAML files that can live:
|
Personas are defined in JSON 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
|
3. Inline via a new `--persona-file` flag (JSON format)
|
||||||
|
|
||||||
### 2. Persona File Format
|
### 2. Persona File Format
|
||||||
|
|
||||||
```yaml
|
```json
|
||||||
# .review/personas/security.yaml
|
# .review/personas/security.yaml
|
||||||
name: security
|
name: security
|
||||||
display_name: Security Specialist
|
display_name: Security Specialist
|
||||||
@@ -77,7 +81,7 @@ output_format: |
|
|||||||
### 3. New CLI Flags
|
### 3. New CLI Flags
|
||||||
|
|
||||||
```
|
```
|
||||||
--persona-file PATH Path to persona YAML file (local or in repo)
|
--persona-file PATH Path to persona JSON file (local or in repo)
|
||||||
--persona NAME Built-in persona name (security, architect, domain)
|
--persona NAME Built-in persona name (security, architect, domain)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -318,36 +322,13 @@ 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: JSON Instead of YAML
|
## Design Revision: YAML with gopkg.in/yaml.v3
|
||||||
|
|
||||||
**Reason:** Project convention is "Go standard library only — no external dependencies."
|
**Decision:** Add `gopkg.in/yaml.v3` as a dependency.
|
||||||
|
|
||||||
YAML requires `gopkg.in/yaml.v3` or similar. To maintain zero dependencies, persona files will use JSON instead.
|
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
|
||||||
|
|
||||||
### Updated Persona File Format
|
The implementation supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) for backwards compatibility, with YAML as the default for built-in personas.
|
||||||
|
|
||||||
```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`.
|
|
||||||
|
|||||||
+4
-34
@@ -7,39 +7,7 @@ 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 {
|
||||||
var sb strings.Builder
|
return FormatMarkdownWithDisplay(result, reviewerName, reviewerName)
|
||||||
|
|
||||||
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.
|
||||||
@@ -55,6 +23,8 @@ 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 {
|
||||||
@@ -67,7 +37,7 @@ func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if headerName != "" {
|
if headerName != "" {
|
||||||
title := strings.ToUpper(headerName[:1]) + headerName[1:]
|
title := CapitalizeFirst(headerName)
|
||||||
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
|
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -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 file path.
|
// LoadPersona loads a persona from a JSON 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(filepath.Join("personas", filename))
|
data, err := embeddedPersonas.ReadFile("personas/" + filename) // embed.FS paths use forward slashes per io/fs spec
|
||||||
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,10 +54,11 @@ 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 nil
|
return []string{}
|
||||||
}
|
}
|
||||||
var names []string
|
var names []string
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@@ -96,3 +97,16 @@ 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 (same as base, but with persona context)
|
// Output format instructions (shared schema from prompt.go)
|
||||||
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,24 +61,10 @@ 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("{\n")
|
sb.WriteString(outputSchemaJSON)
|
||||||
sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
|
sb.WriteString("\n\n")
|
||||||
sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
|
sb.WriteString(verdictRules)
|
||||||
sb.WriteString(" \"findings\": [\n")
|
sb.WriteString("\n- Only report findings within your focus areas. Ignore everything else.\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")
|
||||||
|
|
||||||
@@ -92,7 +78,9 @@ func BuildPersonaSystemPrompt(p *Persona) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildSystemPromptWithPersona constructs the full system prompt, using either
|
// BuildSystemPromptWithPersona constructs the full system prompt, using either
|
||||||
// a persona or the default generic prompt.
|
// a persona or the default generic prompt. This is a convenience wrapper that
|
||||||
|
// combines BuildPersonaSystemPrompt (or BuildSystemBase) with patterns and conventions.
|
||||||
|
// It is exported for use by callers who want one-shot prompt assembly.
|
||||||
func BuildSystemPromptWithPersona(persona *Persona, conventions, patterns string) string {
|
func BuildSystemPromptWithPersona(persona *Persona, conventions, patterns string) string {
|
||||||
var base string
|
var base string
|
||||||
if persona != nil {
|
if persona != nil {
|
||||||
|
|||||||
+43
-15
@@ -3,6 +3,7 @@ package review
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ func TestLoadBuiltinPersona(t *testing.T) {
|
|||||||
name: "architect persona",
|
name: "architect persona",
|
||||||
personaName: "architect",
|
personaName: "architect",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantDisplay: "Architecture Reviewer",
|
wantDisplay: "Software Architect",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "docs persona",
|
name: "docs persona",
|
||||||
@@ -86,16 +87,15 @@ func TestListBuiltinPersonas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadPersonaFromFile(t *testing.T) {
|
func TestLoadPersonaFromJSONFile(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.",
|
"identity": "You are a test persona.\nMulti-line identity works.",
|
||||||
"focus": ["testing"],
|
"focus": ["testing", "validation"],
|
||||||
"ignore": ["nothing"],
|
"ignore": ["nothing"],
|
||||||
"severity": {
|
"severity": {
|
||||||
"major": "Big problems",
|
"major": "Big problems",
|
||||||
@@ -119,6 +119,12 @@ func TestLoadPersonaFromFile(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) {
|
||||||
@@ -158,7 +164,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 !contains(err.Error(), tt.wantErr) {
|
if !strings.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
|
||||||
@@ -187,7 +193,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 json"), 0644); err != nil {
|
if err := os.WriteFile(path, []byte("not valid json {"), 0644); err != nil {
|
||||||
t.Fatalf("failed to write test file: %v", err)
|
t.Fatalf("failed to write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,15 +203,37 @@ func TestLoadPersonaInvalidJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
func TestCapitalizeFirst(t *testing.T) {
|
||||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
|
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 containsHelper(s, substr string) bool {
|
func TestListBuiltinPersonasReturnsEmptySlice(t *testing.T) {
|
||||||
for i := 0; i <= len(s)-len(substr); i++ {
|
// ListBuiltinPersonas should return an empty slice (not nil) on error.
|
||||||
if s[i:i+len(substr)] == substr {
|
// We can't easily test the error case, but we can verify the success case
|
||||||
return true
|
// returns a proper slice.
|
||||||
}
|
names := ListBuiltinPersonas()
|
||||||
|
if names == nil {
|
||||||
|
t.Error("ListBuiltinPersonas should return empty slice, not nil")
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "architect",
|
"name": "architect",
|
||||||
"display_name": "Architecture Reviewer",
|
"display_name": "Software Architect",
|
||||||
"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",
|
"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": [
|
"focus": [
|
||||||
"Design pattern violations or misapplications",
|
"Design pattern violations or misuse",
|
||||||
"Module boundary violations and improper coupling",
|
"Module boundary violations (inappropriate coupling)",
|
||||||
"API contract clarity and consistency",
|
"API design issues (unclear contracts, leaky abstractions)",
|
||||||
"Error handling completeness and patterns",
|
"Testability problems (hidden dependencies, god objects)",
|
||||||
"Concurrency safety and patterns",
|
"Inconsistency with existing codebase patterns",
|
||||||
"Testability and dependency injection",
|
"Unnecessary complexity or over-engineering",
|
||||||
"Separation of concerns"
|
"Missing abstractions or premature abstraction"
|
||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"Security vulnerabilities (handled by security persona)",
|
"Security vulnerabilities (security persona handles these)",
|
||||||
"Performance micro-optimizations",
|
"Performance micro-optimizations",
|
||||||
"Minor style preferences",
|
"Code style and formatting",
|
||||||
"Documentation formatting"
|
"Documentation typos",
|
||||||
|
"Test implementation details"
|
||||||
],
|
],
|
||||||
"severity": {
|
"severity": {
|
||||||
"major": "Design issues that will cause maintenance burden or bugs: tight coupling, missing abstractions, broken contracts",
|
"major": "Architectural violations that will cause maintenance problems or make the codebase harder to evolve",
|
||||||
"minor": "Suboptimal patterns that could be improved: redundant code, unclear boundaries",
|
"minor": "Design issues that reduce clarity or testability but don't block progress",
|
||||||
"nit": "Style suggestions that improve consistency but don't affect correctness"
|
"nit": "Minor pattern deviations or style preferences"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-14
@@ -1,24 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "docs",
|
"name": "docs",
|
||||||
"display_name": "Documentation Reviewer",
|
"display_name": "Documentation Reviewer",
|
||||||
"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",
|
"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": [
|
"focus": [
|
||||||
"Missing or outdated API documentation",
|
"Missing or outdated documentation",
|
||||||
"Misleading or incorrect code comments",
|
"Unclear or misleading comments",
|
||||||
"Unclear error messages",
|
"Poor error messages (cryptic, unhelpful, missing context)",
|
||||||
"Missing or incorrect examples",
|
"Confusing naming (functions, variables, types)",
|
||||||
"README accuracy and completeness",
|
"Missing examples for complex APIs",
|
||||||
"Public API ergonomics and naming"
|
"Inconsistent terminology",
|
||||||
|
"Documentation that contradicts the code"
|
||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"Implementation details (unless they affect the public API)",
|
"Security vulnerabilities",
|
||||||
"Performance",
|
"Performance issues",
|
||||||
"Security (handled by security persona)",
|
"Design patterns",
|
||||||
"Internal code organization"
|
"Test coverage",
|
||||||
|
"Code style (unless it affects readability)"
|
||||||
],
|
],
|
||||||
"severity": {
|
"severity": {
|
||||||
"major": "Misleading documentation that will cause users to make mistakes",
|
"major": "Documentation that actively misleads or missing docs for critical functionality",
|
||||||
"minor": "Missing documentation for public APIs",
|
"minor": "Unclear documentation or poor error messages that will confuse users",
|
||||||
"nit": "Minor wording improvements or formatting"
|
"nit": "Minor clarity improvements or typo fixes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-18
@@ -7,6 +7,28 @@ 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.
|
||||||
@@ -23,24 +45,10 @@ 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("{\n")
|
sb.WriteString(outputSchemaJSON)
|
||||||
sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
|
sb.WriteString("\n\n")
|
||||||
sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
|
sb.WriteString(verdictRules)
|
||||||
sb.WriteString(" \"findings\": [\n")
|
sb.WriteString("\n- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\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