Initial implementation: AI code review bot for Gitea

- CLI binary with flag/env var configuration
- Gitea API client (PR metadata, diff, CI status, post review)
- OpenAI-compatible LLM client
- Structured review prompt with conventions support
- JSON response parser with validation
- Markdown review formatter for Gitea
- CI failure auto-detection (REQUEST_CHANGES)
- Dry-run mode for testing
This commit is contained in:
Rodin
2026-05-01 09:42:45 -07:00
commit 700f186023
9 changed files with 673 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
package review
import (
"fmt"
"strings"
)
// FormatMarkdown formats a ReviewResult into the markdown body for a Gitea review.
func FormatMarkdown(result *ReviewResult, reviewerName string) string {
var sb strings.Builder
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))
}
return sb.String()
}
// GiteaEvent converts the verdict to the Gitea API event string.
func GiteaEvent(verdict string) string {
switch verdict {
case "APPROVE":
return "APPROVED"
case "REQUEST_CHANGES":
return "REQUEST_CHANGES"
default:
return "COMMENT"
}
}
+76
View File
@@ -0,0 +1,76 @@
package review
import (
"encoding/json"
"fmt"
"strings"
)
// Finding represents a single code review finding.
type Finding struct {
Severity string `json:"severity"`
File string `json:"file"`
Line int `json:"line"`
Finding string `json:"finding"`
}
// ReviewResult is the structured output from the LLM.
type ReviewResult struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
Findings []Finding `json:"findings"`
Recommendation string `json:"recommendation"`
}
// ParseResponse parses the LLM response into a ReviewResult.
func ParseResponse(response string) (*ReviewResult, error) {
// Try to extract JSON from the response — the LLM might wrap it in markdown fences
cleaned := extractJSON(response)
var result ReviewResult
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return nil, fmt.Errorf("parse LLM response as JSON: %w\nRaw response: %s", err, response)
}
// Validate verdict
switch result.Verdict {
case "APPROVE", "REQUEST_CHANGES":
// valid
default:
return nil, fmt.Errorf("invalid verdict %q (must be APPROVE or REQUEST_CHANGES)", result.Verdict)
}
// Validate finding severities
for i, f := range result.Findings {
switch f.Severity {
case "MAJOR", "MINOR", "NIT":
// valid
default:
return nil, fmt.Errorf("finding %d has invalid severity %q", i, f.Severity)
}
}
return &result, nil
}
// extractJSON attempts to pull JSON from a potentially markdown-wrapped response.
func extractJSON(s string) string {
s = strings.TrimSpace(s)
// Remove markdown code fences if present
if strings.HasPrefix(s, "```") {
lines := strings.Split(s, "\n")
// Remove first line (```json or ```)
if len(lines) > 2 {
lines = lines[1:]
}
// Remove last line (```)
if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "```" {
lines = lines[:len(lines)-1]
}
s = strings.Join(lines, "\n")
}
s = strings.TrimSpace(s)
return s
}
+72
View File
@@ -0,0 +1,72 @@
package review
import (
"fmt"
"strings"
)
// BuildSystemPrompt constructs the system prompt for the LLM reviewer.
func BuildSystemPrompt(conventions string) string {
var sb strings.Builder
sb.WriteString("You are an expert code reviewer. Review the provided pull request diff carefully.\n\n")
sb.WriteString("Your task:\n")
sb.WriteString("1. Review the diff for correctness, idiomatic code, potential bugs, and design issues.\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("Output format:\n")
sb.WriteString("{\n")
sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
sb.WriteString(" \"findings\": [\n")
sb.WriteString(" {\n")
sb.WriteString(" \"severity\": \"MAJOR\" or \"MINOR\" or \"NIT\",\n")
sb.WriteString(" \"file\": \"path/to/file\",\n")
sb.WriteString(" \"line\": <line number from the diff>,\n")
sb.WriteString(" \"finding\": \"Description of the issue\"\n")
sb.WriteString(" }\n")
sb.WriteString(" ],\n")
sb.WriteString(" \"recommendation\": \"Full recommendation text explaining your verdict\"\n")
sb.WriteString("}\n\n")
sb.WriteString("Rules:\n")
sb.WriteString("- If there are any MAJOR findings → verdict must be REQUEST_CHANGES\n")
sb.WriteString("- If there are no MAJOR findings → verdict should be APPROVE\n")
sb.WriteString("- If CI has failed → verdict must be REQUEST_CHANGES with a finding noting the CI failure\n")
sb.WriteString("- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\n")
sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n")
sb.WriteString("- If the diff is empty or trivial (only formatting/whitespace), APPROVE with no findings.\n")
if conventions != "" {
sb.WriteString(fmt.Sprintf("\n\nThe repository has the following coding conventions that should be respected:\n\n%s\n", conventions))
}
return sb.String()
}
// BuildUserPrompt constructs the user message with PR context.
func BuildUserPrompt(title, description, diff string, ciPassed bool, ciDetails string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Pull Request: %s\n\n", title))
if description != "" {
sb.WriteString(fmt.Sprintf("### Description\n%s\n\n", description))
}
ciStatus := "PASSED"
if !ciPassed {
ciStatus = "FAILED"
}
sb.WriteString(fmt.Sprintf("### CI Status: %s\n", ciStatus))
if ciDetails != "" {
sb.WriteString(fmt.Sprintf("CI Details: %s\n", ciDetails))
}
sb.WriteString("\n### Diff\n\n")
sb.WriteString("```diff\n")
sb.WriteString(diff)
sb.WriteString("\n```\n")
return sb.String()
}