Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d50707332 |
+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"
|
||||||
|
|||||||
@@ -318,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`.
|
||||||
|
|||||||
+33
-3
@@ -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 {
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,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 + ".yaml"
|
filename := name + ".yaml"
|
||||||
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, ", "))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+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