feat: full file context + patterns-repo support
Major improvements to review quality: 1. Full file context: fetch complete content of all modified files from the PR branch and include as reference. This eliminates false-positive "missing import" findings since the model sees the entire file. 2. Patterns repo: new --patterns-repo / PATTERNS_REPO flag fetches language idiom files from a separate Gitea repo (e.g. rodin/elixir-patterns) and includes them as review criteria. 3. Multi-file patterns: --patterns-files / PATTERNS_FILES accepts comma-separated file paths to fetch from the patterns repo. New API methods: - GetPullRequestFiles: list changed files in a PR - GetFileContentRef: fetch file content from a specific branch/ref Prompt changes: - BuildSystemPrompt now accepts (conventions, patterns) - BuildUserPrompt now accepts fileContext parameter - File context displayed before diff for model reference - Patterns presented as "review criteria" in system prompt Composite action updated with patterns-repo and patterns-files inputs.
This commit is contained in:
@@ -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=""
|
||||
|
||||
+72
-9
@@ -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 {
|
||||
|
||||
+34
-3
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -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")
|
||||
|
||||
+47
-6
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user