diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index f218a02..2670ba9 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -37,6 +37,14 @@ inputs: description: 'Path to conventions file in the repo (e.g. CLAUDE.md)' required: false default: '' + patterns-repo: + description: 'Repo with language patterns (e.g. rodin/elixir-patterns)' + required: false + default: '' + patterns-files: + description: 'Comma-separated file paths to fetch from patterns repo' + required: false + default: 'README.md' temperature: description: 'LLM temperature (0 = server default)' required: false @@ -123,6 +131,8 @@ runs: LLM_API_KEY: ${{ inputs.llm-api-key }} LLM_MODEL: ${{ inputs.llm-model }} CONVENTIONS_FILE: ${{ inputs.conventions-file }} + PATTERNS_REPO: ${{ inputs.patterns-repo }} + PATTERNS_FILES: ${{ inputs.patterns-files }} LLM_TEMPERATURE: ${{ inputs.temperature }} run: | ARGS="" diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 48b6742..470cc0f 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -1,6 +1,5 @@ package main - import ( "flag" "fmt" @@ -27,6 +26,8 @@ func main() { llmAPIKey := flag.String("llm-api-key", envOrDefault("LLM_API_KEY", ""), "LLM API key") llmModel := flag.String("llm-model", envOrDefault("LLM_MODEL", ""), "LLM model name") conventionsFile := flag.String("conventions-file", envOrDefault("CONVENTIONS_FILE", ""), "Conventions file path in repo (e.g. CLAUDE.md)") + patternsRepo := flag.String("patterns-repo", envOrDefault("PATTERNS_REPO", ""), "Repo with language patterns (e.g. rodin/elixir-patterns)") + patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo") dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting") llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)") @@ -79,7 +80,17 @@ func main() { } log.Printf("Diff size: %d bytes", len(diff)) - // Step 3: Check CI status + // Step 3: Fetch full file content for modified files + fileContext := "" + files, err := giteaClient.GetPullRequestFiles(owner, repoName, prNumber) + if err != nil { + log.Printf("Warning: could not fetch PR files list: %v", err) + } else { + fileContext = fetchFileContext(giteaClient, owner, repoName, pr.Head.Ref, files) + log.Printf("Fetched full context for %d files", len(files)) + } + + // Step 4: Check CI status ciPassed := true ciDetails := "" if pr.Head.Sha != "" { @@ -92,7 +103,7 @@ func main() { } } - // Step 4: Load conventions file if specified + // Step 5: Load conventions file if specified conventions := "" if *conventionsFile != "" { content, err := giteaClient.GetFileContent(owner, repoName, *conventionsFile) @@ -104,11 +115,18 @@ func main() { } } - // Step 5: Build prompts - systemPrompt := review.BuildSystemPrompt(conventions) - userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, ciPassed, ciDetails) + // Step 6: Load patterns from external repo if specified + patterns := "" + if *patternsRepo != "" { + patterns = fetchPatterns(giteaClient, *patternsRepo, *patternsFiles) + log.Printf("Loaded patterns from %s (%d bytes)", *patternsRepo, len(patterns)) + } - // Step 6: Call LLM + // Step 7: Build prompts + systemPrompt := review.BuildSystemPrompt(conventions, patterns) + userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, fileContext, ciPassed, ciDetails) + + // Step 8: Call LLM log.Printf("Sending to LLM (%s)...", *llmModel) messages := []llm.Message{ {Role: "system", Content: systemPrompt}, @@ -121,14 +139,14 @@ func main() { } log.Printf("LLM response received (%d bytes)", len(response)) - // Step 7: Parse response + // Step 9: Parse response result, err := review.ParseResponse(response) if err != nil { log.Fatalf("Failed to parse LLM response: %v", err) } log.Printf("Verdict: %s (%d findings)", result.Verdict, len(result.Findings)) - // Step 8: Format and post review + // Step 10: Format and post review reviewBody := review.FormatMarkdown(result, *reviewerName) event := review.GiteaEvent(result.Verdict) @@ -146,6 +164,51 @@ func main() { log.Printf("Review posted successfully!") } +// fetchFileContext fetches the full content of modified files from the PR branch. +func fetchFileContext(client *gitea.Client, owner, repo, ref string, files []gitea.ChangedFile) string { + var sb strings.Builder + for _, f := range files { + if f.Status == "removed" { + continue // Skip deleted files + } + content, err := client.GetFileContentRef(owner, repo, f.Filename, ref) + if err != nil { + log.Printf("Warning: could not fetch %s: %v", f.Filename, err) + continue + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", f.Filename)) + sb.WriteString("```\n") + sb.WriteString(content) + sb.WriteString("\n```\n\n") + } + return sb.String() +} + +// fetchPatterns fetches pattern files from an external repo. +func fetchPatterns(client *gitea.Client, patternsRepo, patternsFiles string) string { + parts := strings.SplitN(patternsRepo, "/", 2) + if len(parts) != 2 { + log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", patternsRepo) + return "" + } + owner, repo := parts[0], parts[1] + + var sb strings.Builder + for _, filepath := range strings.Split(patternsFiles, ",") { + filepath = strings.TrimSpace(filepath) + if filepath == "" { + continue + } + content, err := client.GetFileContent(owner, repo, filepath) + if err != nil { + log.Printf("Warning: could not fetch pattern file %s from %s: %v", filepath, patternsRepo, err) + continue + } + sb.WriteString(fmt.Sprintf("### %s/%s\n\n%s\n\n", patternsRepo, filepath, content)) + } + return sb.String() +} + // evaluateCIStatus checks if all CI statuses indicate success. func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) { if len(statuses) == 0 { diff --git a/gitea/client.go b/gitea/client.go index e225f5c..84caf8d 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -26,10 +26,11 @@ func NewClient(baseURL, token string) *Client { // PullRequest holds relevant PR metadata. type PullRequest struct { - Title string `json:"title"` - Body string `json:"body"` - Head struct { + Title string `json:"title"` + Body string `json:"body"` + Head struct { Sha string `json:"sha"` + Ref string `json:"ref"` } `json:"head"` } @@ -41,6 +42,12 @@ type CommitStatus struct { TargetURL string `json:"target_url"` } +// ChangedFile represents a file modified in a PR. +type ChangedFile struct { + Filename string `json:"filename"` + Status string `json:"status"` +} + // GetPullRequest fetches PR metadata. func (c *Client) GetPullRequest(owner, repo string, number int) (*PullRequest, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.BaseURL, owner, repo, number) @@ -65,6 +72,20 @@ func (c *Client) GetPullRequestDiff(owner, repo string, number int) (string, err return string(body), nil } +// GetPullRequestFiles fetches the list of files changed in a PR. +func (c *Client) GetPullRequestFiles(owner, repo string, number int) ([]ChangedFile, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.BaseURL, owner, repo, number) + body, err := c.doGet(url) + if err != nil { + return nil, fmt.Errorf("fetch PR files: %w", err) + } + var files []ChangedFile + if err := json.Unmarshal(body, &files); err != nil { + return nil, fmt.Errorf("parse PR files JSON: %w", err) + } + return files, nil +} + // GetCommitStatuses fetches CI statuses for a commit SHA. func (c *Client) GetCommitStatuses(owner, repo, sha string) ([]CommitStatus, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.BaseURL, owner, repo, sha) @@ -89,6 +110,16 @@ func (c *Client) GetFileContent(owner, repo, filepath string) (string, error) { return string(body), nil } +// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo. +func (c *Client) GetFileContentRef(owner, repo, filepath, ref string) (string, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.BaseURL, owner, repo, filepath, ref) + body, err := c.doGet(url) + if err != nil { + return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err) + } + return string(body), nil +} + // PostReview submits a review to a PR. // event should be "APPROVED" or "REQUEST_CHANGES". func (c *Client) PostReview(owner, repo string, number int, event, body string) error { diff --git a/gitea/client_test.go b/gitea/client_test.go index f84b2ee..600ff5c 100644 --- a/gitea/client_test.go +++ b/gitea/client_test.go @@ -193,3 +193,48 @@ func TestGetFileContent(t *testing.T) { t.Errorf("expected %q, got %q", expected, got) } } + +func TestGetPullRequestFiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/owner/repo/pulls/1/files" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"filename":"main.go","status":"modified"},{"filename":"old.go","status":"removed"}]`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + files, err := client.GetPullRequestFiles("owner", "repo", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } + if files[0].Filename != "main.go" || files[0].Status != "modified" { + t.Errorf("unexpected first file: %+v", files[0]) + } +} + +func TestGetFileContentRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/owner/repo/raw/main.go" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("ref") != "feature-branch" { + t.Errorf("unexpected ref: %s", r.URL.Query().Get("ref")) + } + w.Write([]byte("package main\n")) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + content, err := client.GetFileContentRef("owner", "repo", "main.go", "feature-branch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if content != "package main\n" { + t.Errorf("unexpected content: %q", content) + } +} diff --git a/review/prompt.go b/review/prompt.go index e530c69..1011906 100644 --- a/review/prompt.go +++ b/review/prompt.go @@ -6,15 +6,14 @@ import ( ) // BuildSystemPrompt constructs the system prompt for the LLM reviewer. -func BuildSystemPrompt(conventions string) string { +func BuildSystemPrompt(conventions, patterns 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("IMPORTANT CONTEXT:\n") - sb.WriteString("- You are reviewing a DIFF, not the complete file. Code not shown in the diff already exists in the repository.\n") - sb.WriteString("- Imports, type definitions, functions, and other declarations that do not appear in the diff are already present in the file.\n") - sb.WriteString("- Do NOT flag missing imports, missing type definitions, or undefined references unless the diff itself introduces a new usage without a corresponding addition in the same diff.\n") - sb.WriteString("- Only flag issues with code that is actually being ADDED or MODIFIED in this diff.\n\n") + sb.WriteString("CONTEXT:\n") + sb.WriteString("- You will receive the full content of modified files for reference, followed by the diff showing what changed.\n") + sb.WriteString("- The diff shows ONLY what was added/removed. The full file content provides complete context.\n") + sb.WriteString("- Focus your review on the CHANGES (the diff), using the full files for context.\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") @@ -40,17 +39,20 @@ func BuildSystemPrompt(conventions string) string { 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") - sb.WriteString("- Never flag 'missing imports' or 'undefined' errors for symbols that could exist in the unchanged portions of the file.\n") + + if patterns != "" { + sb.WriteString(fmt.Sprintf("\n\n## Language Patterns & Idioms\n\nUse the following patterns as review criteria. Code that violates these established patterns is a finding:\n\n%s\n", patterns)) + } if conventions != "" { - sb.WriteString(fmt.Sprintf("\n\nThe repository has the following coding conventions that should be respected:\n\n%s\n", conventions)) + sb.WriteString(fmt.Sprintf("\n\n## Repository Conventions\n\nThe repository has the following coding conventions that must 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 { +func BuildUserPrompt(title, description, diff, fileContext string, ciPassed bool, ciDetails string) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("## Pull Request: %s\n\n", title)) @@ -69,7 +71,13 @@ func BuildUserPrompt(title, description, diff string, ciPassed bool, ciDetails s sb.WriteString(fmt.Sprintf("CI Details: %s\n", ciDetails)) } - sb.WriteString("\n### Diff\n\n") + if fileContext != "" { + sb.WriteString("\n### Full File Context (modified files)\n\n") + sb.WriteString(fileContext) + sb.WriteString("\n") + } + + sb.WriteString("\n### Diff (changes to review)\n\n") sb.WriteString("```diff\n") sb.WriteString(diff) sb.WriteString("\n```\n") diff --git a/review/prompt_test.go b/review/prompt_test.go index c224619..c3c1d2a 100644 --- a/review/prompt_test.go +++ b/review/prompt_test.go @@ -6,7 +6,7 @@ import ( ) func TestBuildSystemPrompt_NoConventions(t *testing.T) { - prompt := BuildSystemPrompt("") + prompt := BuildSystemPrompt("", "") if !strings.Contains(prompt, "expert code reviewer") { t.Error("expected system prompt to mention code reviewer role") @@ -18,7 +18,7 @@ func TestBuildSystemPrompt_NoConventions(t *testing.T) { func TestBuildSystemPrompt_WithConventions(t *testing.T) { conventions := "- Use stdlib only\n- No panics\n" - prompt := BuildSystemPrompt(conventions) + prompt := BuildSystemPrompt(conventions, "") if !strings.Contains(prompt, "coding conventions") { t.Error("expected conventions section") @@ -29,7 +29,7 @@ func TestBuildSystemPrompt_WithConventions(t *testing.T) { } func TestBuildUserPrompt_Basic(t *testing.T) { - prompt := BuildUserPrompt("Fix bug", "Fixes the crash", "diff content here", true, "all checks passed") + prompt := BuildUserPrompt("Fix bug", "Fixes the crash", "diff content here", "", true, "all checks passed") if !strings.Contains(prompt, "Fix bug") { t.Error("expected PR title") @@ -46,7 +46,7 @@ func TestBuildUserPrompt_Basic(t *testing.T) { } func TestBuildUserPrompt_CIFailed(t *testing.T) { - prompt := BuildUserPrompt("Add tests", "", "some diff", false, "lint: failed") + prompt := BuildUserPrompt("Add tests", "", "some diff", "", false, "lint: failed") if !strings.Contains(prompt, "FAILED") { t.Error("expected CI status FAILED") @@ -57,7 +57,7 @@ func TestBuildUserPrompt_CIFailed(t *testing.T) { } func TestBuildUserPrompt_NoDescription(t *testing.T) { - prompt := BuildUserPrompt("Quick fix", "", "diff", true, "") + prompt := BuildUserPrompt("Quick fix", "", "diff", "", true, "") if strings.Contains(prompt, "### Description") { t.Error("should not contain Description header when body is empty") @@ -66,7 +66,7 @@ func TestBuildUserPrompt_NoDescription(t *testing.T) { func TestBuildUserPrompt_DiffIncluded(t *testing.T) { diff := "+func Hello() string {\n+\treturn \"hello\"\n+}" - prompt := BuildUserPrompt("Greeting", "Add greeting func", diff, true, "") + prompt := BuildUserPrompt("Greeting", "Add greeting func", diff, "", true, "") if !strings.Contains(prompt, "```diff") { t.Error("expected diff fence") @@ -75,3 +75,44 @@ func TestBuildUserPrompt_DiffIncluded(t *testing.T) { t.Error("expected diff content in prompt") } } + +func TestBuildSystemPrompt_WithPatterns(t *testing.T) { + patterns := "## Naming: use snake_case for functions" + prompt := BuildSystemPrompt("", patterns) + if !strings.Contains(prompt, "Language Patterns") { + t.Error("expected patterns section header") + } + if !strings.Contains(prompt, "snake_case") { + t.Error("expected patterns content") + } +} + +func TestBuildSystemPrompt_WithBoth(t *testing.T) { + conventions := "Run mix format before commit" + patterns := "Use pipe operator for transformations" + prompt := BuildSystemPrompt(conventions, patterns) + if !strings.Contains(prompt, "Repository Conventions") { + t.Error("expected conventions section") + } + if !strings.Contains(prompt, "Language Patterns") { + t.Error("expected patterns section") + } +} + +func TestBuildUserPrompt_WithFileContext(t *testing.T) { + fileContext := "--- main.go ---\npackage main\n" + prompt := BuildUserPrompt("Fix", "desc", "diff here", fileContext, true, "") + if !strings.Contains(prompt, "Full File Context") { + t.Error("expected file context section") + } + if !strings.Contains(prompt, "package main") { + t.Error("expected file content in prompt") + } +} + +func TestBuildUserPrompt_WithoutFileContext(t *testing.T) { + prompt := BuildUserPrompt("Fix", "desc", "diff here", "", true, "") + if strings.Contains(prompt, "Full File Context") { + t.Error("should not include file context section when empty") + } +}