diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml new file mode 100644 index 0000000..2670ba9 --- /dev/null +++ b/.gitea/actions/review/action.yml @@ -0,0 +1,142 @@ +# This composite action is designed for Gitea Actions runners. +# Gitea Actions supports GitHub Actions syntax including $GITHUB_OUTPUT, +# actions/cache, and actions/checkout. +name: 'AI Code Review' +description: 'Run AI-powered code review on a pull request using review-bot' + +inputs: + gitea-url: + description: 'Gitea instance URL (defaults to server_url)' + required: false + default: '' + repo: + description: 'Repository (owner/name, defaults to current)' + required: false + default: '' + pr-number: + description: 'Pull request number (defaults to current PR)' + required: false + default: '' + reviewer-token: + description: 'Gitea token for posting the review' + required: true + reviewer-name: + description: 'Display name for the reviewer' + required: false + default: '' + llm-base-url: + description: 'OpenAI-compatible LLM API base URL' + required: true + llm-api-key: + description: 'LLM API key' + required: true + llm-model: + description: 'LLM model name' + required: true + conventions-file: + 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 + default: '0' + version: + description: 'review-bot version to install (e.g. v0.1.0, defaults to latest)' + required: false + default: 'latest' + dry-run: + description: 'Print review to stdout instead of posting' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: Determine version + id: version + shell: bash + run: | + GITEA_URL="${{ inputs.gitea-url || github.server_url }}" + REPO="${{ inputs.repo || 'rodin/review-bot' }}" + if [ "${{ inputs.version }}" = "latest" ]; then + VERSION=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" \ + | python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')") + if [ -z "$VERSION" ]; then + echo "Failed to determine latest version" >&2 + exit 1 + fi + else + VERSION="${{ inputs.version }}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Cache review-bot binary + id: cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/review-bot + key: review-bot-linux-amd64-${{ steps.version.outputs.version }} + + - name: Install review-bot + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: | + GITEA_URL="${{ inputs.gitea-url || github.server_url }}" + REPO="${{ inputs.repo || 'rodin/review-bot' }}" + VERSION="${{ steps.version.outputs.version }}" + BINARY="review-bot-linux-amd64" + + curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/${BINARY}" \ + -o "${{ runner.temp }}/review-bot" + curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/checksums.txt" \ + -o "${{ runner.temp }}/checksums.txt" + + # Verify SHA-256 checksum + cd "${{ runner.temp }}" + EXPECTED=$(grep "${BINARY}" checksums.txt | awk '{print $1}') + ACTUAL=$(sha256sum review-bot | awk '{print $1}') + + if [ -z "$EXPECTED" ]; then + echo "Error: no checksum found for ${BINARY}" >&2 + exit 1 + fi + if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "Error: checksum mismatch!" >&2 + echo " Expected: $EXPECTED" >&2 + echo " Actual: $ACTUAL" >&2 + exit 1 + fi + + chmod +x "${{ runner.temp }}/review-bot" + echo "Installed review-bot ${VERSION} (checksum verified)" + + - name: Run review + shell: bash + env: + GITEA_URL: ${{ inputs.gitea-url || github.server_url }} + GITEA_REPO: ${{ inputs.repo || github.repository }} + PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }} + REVIEWER_TOKEN: ${{ inputs.reviewer-token }} + REVIEWER_NAME: ${{ inputs.reviewer-name }} + LLM_BASE_URL: ${{ inputs.llm-base-url }} + 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="" + if [ "${{ inputs.dry-run }}" = "true" ]; then + ARGS="--dry-run" + fi + ${{ runner.temp }}/review-bot $ARGS diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e0a5214..6adf9d9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,4 +1,5 @@ name: CI + on: push: branches: [main] @@ -12,42 +13,41 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version: '1.26' - run: go test ./... - run: go vet ./... - run: go build -o review-bot ./cmd/review-bot + # Self-review: builds from source since we're pre-release review: runs-on: ubuntu-24.04 if: github.event_name == 'pull_request' needs: test + strategy: + matrix: + include: + - name: sonnet + token_secret: SONNET_REVIEW_TOKEN + model: gpt-5 + - name: gpt + token_secret: GPT_REVIEW_TOKEN + model: gpt-5-mini steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version: '1.26' - run: go build -o review-bot ./cmd/review-bot - - name: Run Sonnet Review + - name: Run ${{ matrix.name }} review env: GITEA_URL: ${{ github.server_url }} GITEA_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - REVIEWER_TOKEN: ${{ secrets.SONNET_REVIEW_TOKEN }} + REVIEWER_TOKEN: ${{ secrets[matrix.token_secret] }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - LLM_MODEL: "anthropic--claude-4.6-sonnet" + LLM_MODEL: ${{ matrix.model }} CONVENTIONS_FILE: "CONVENTIONS.md" - REVIEWER_NAME: "Sonnet" - run: ./review-bot - - name: Run GPT Review - env: - GITEA_URL: ${{ github.server_url }} - GITEA_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REVIEWER_TOKEN: ${{ secrets.GPT_REVIEW_TOKEN }} - LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - LLM_MODEL: "sap-ai-opus-latest-openai/gpt-5" - CONVENTIONS_FILE: "CONVENTIONS.md" - REVIEWER_NAME: "GPT" + PATTERNS_REPO: "rodin/go-patterns" + PATTERNS_FILES: "README.md,docs/" run: ./review-bot diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..b268f29 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Run tests + run: go test ./... + + - name: Build binaries + run: | + VERSION=${GITHUB_REF_NAME} + mkdir -p dist + + GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-linux-amd64 ./cmd/review-bot + GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-linux-arm64 ./cmd/review-bot + GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-darwin-amd64 ./cmd/review-bot + GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-darwin-arm64 ./cmd/review-bot + + cd dist && sha256sum * > checksums.txt + + - name: Create release and upload assets + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + VERSION=${GITHUB_REF_NAME} + GITEA_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + + # Create release (or find existing one for this tag) + HTTP_CODE=$(curl -s -o /tmp/release_response.json -w "%{http_code}" -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" \ + -d "{\"tag_name\": \"${VERSION}\", \"name\": \"${VERSION}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}") + + if [ "$HTTP_CODE" = "409" ]; then + echo "Release for ${VERSION} already exists, fetching existing..." + curl -sSf -o /tmp/release_response.json \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${VERSION}" + elif [ "$HTTP_CODE" != "201" ]; then + echo "Failed to create release (HTTP ${HTTP_CODE})" >&2 + cat /tmp/release_response.json >&2 + exit 1 + fi + + # Parse release ID (python3 available on ubuntu-24.04 runners) + RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release_response.json'))['id'])") + + if [ -z "$RELEASE_ID" ]; then + echo "Failed to parse release ID" >&2 + cat /tmp/release_response.json >&2 + exit 1 + fi + + echo "Release ID: ${RELEASE_ID}" + + # Upload each asset + for file in dist/*; do + filename=$(basename "$file") + echo "Uploading ${filename}..." + curl -sSf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \ + --data-binary "@${file}" + done + + echo "Release ${VERSION} created with assets" diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 3fcc3c8..8715b6e 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -13,6 +13,8 @@ import ( "gitea.weiker.me/rodin/review-bot/review" ) +var version = "dev" + func main() { // CLI flags giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL") @@ -24,7 +26,10 @@ 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)") flag.Parse() @@ -52,6 +57,12 @@ func main() { // Initialize clients giteaClient := gitea.NewClient(*giteaURL, *reviewerToken) llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel) + if *llmTemp < 0 || *llmTemp > 2 { + log.Fatal("--llm-temperature must be between 0 and 2") + } + if *llmTemp > 0 { + llmClient.WithTemperature(*llmTemp) + } log.Printf("Reviewing PR #%d on %s/%s", prNumber, owner, repoName) @@ -69,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 != "" { @@ -82,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) @@ -94,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}, @@ -111,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) @@ -136,6 +164,81 @@ 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 one or more external repos. +// patternsRepo is comma-separated list of owner/name repos. +// patternsFiles is comma-separated list of file paths or directories. +// If a path ends with / or is a directory, all files within it are fetched recursively. +func fetchPatterns(client *gitea.Client, patternsRepo, patternsFiles string) string { + var sb strings.Builder + + repos := strings.Split(patternsRepo, ",") + paths := strings.Split(patternsFiles, ",") + + for _, repoRef := range repos { + repoRef = strings.TrimSpace(repoRef) + if repoRef == "" { + continue + } + parts := strings.SplitN(repoRef, "/", 2) + if len(parts) != 2 { + log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", repoRef) + continue + } + owner, repo := parts[0], parts[1] + + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + + files, err := client.GetAllFilesInPath(owner, repo, path) + if err != nil { + log.Printf("Warning: could not fetch %s from %s: %v", path, repoRef, err) + continue + } + + for filepath, content := range files { + // Only include markdown and text files as patterns + if !isPatternFile(filepath) { + continue + } + sb.WriteString(fmt.Sprintf("### %s/%s\n\n%s\n\n", repoRef, filepath, content)) + } + } + } + return sb.String() +} + +// isPatternFile returns true if the file should be included as pattern content. +func isPatternFile(path string) bool { + lower := strings.ToLower(path) + return strings.HasSuffix(lower, ".md") || + strings.HasSuffix(lower, ".txt") || + strings.HasSuffix(lower, ".yml") || + strings.HasSuffix(lower, ".yaml") +} + // evaluateCIStatus checks if all CI statuses indicate success. func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) { if len(statuses) == 0 { @@ -166,3 +269,13 @@ func envOrDefault(key, defaultVal string) string { } return defaultVal } + +func envOrDefaultFloat(key string, defaultVal float64) float64 { + if v := os.Getenv(key); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err == nil { + return f + } + } + return defaultVal +} diff --git a/gitea/client.go b/gitea/client.go index e225f5c..71607f4 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 { @@ -146,3 +177,63 @@ func (c *Client) doGet(url string) ([]byte, error) { } return io.ReadAll(resp.Body) } + +// ContentEntry represents a file or directory entry from the contents API. +type ContentEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "dir" +} + +// ListContents lists files and directories at a given path in a repo. +func (c *Client) ListContents(owner, repo, path string) ([]ContentEntry, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.BaseURL, owner, repo, path) + body, err := c.doGet(url) + if err != nil { + return nil, fmt.Errorf("list contents %s: %w", path, err) + } + var entries []ContentEntry + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("parse contents JSON: %w", err) + } + return entries, nil +} + +// GetAllFilesInPath recursively fetches all file contents under a path. +// If the path is a file, returns just that file's content. +// If the path is a directory, recursively fetches all files within it. +func (c *Client) GetAllFilesInPath(owner, repo, path string) (map[string]string, error) { + results := make(map[string]string) + + // Try listing as directory first + entries, err := c.ListContents(owner, repo, path) + if err != nil { + // Might be a file, try fetching directly + content, fileErr := c.GetFileContent(owner, repo, path) + if fileErr != nil { + return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err) + } + results[path] = content + return results, nil + } + + for _, entry := range entries { + switch entry.Type { + case "file": + content, err := c.GetFileContent(owner, repo, entry.Path) + if err != nil { + continue // Skip files we can't read + } + results[entry.Path] = content + case "dir": + subResults, err := c.GetAllFilesInPath(owner, repo, entry.Path) + if err != nil { + continue + } + for k, v := range subResults { + results[k] = v + } + } + } + return results, nil +} diff --git a/gitea/client_test.go b/gitea/client_test.go index f84b2ee..94ff233 100644 --- a/gitea/client_test.go +++ b/gitea/client_test.go @@ -2,6 +2,7 @@ package gitea import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -193,3 +194,102 @@ 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) + } +} + +func TestListContents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/owner/repo/contents/docs" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"name":"guide.md","path":"docs/guide.md","type":"file"},{"name":"sub","path":"docs/sub","type":"dir"}]`) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + entries, err := client.ListContents("owner", "repo", "docs") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Type != "file" || entries[0].Path != "docs/guide.md" { + t.Errorf("unexpected first entry: %+v", entries[0]) + } + if entries[1].Type != "dir" { + t.Errorf("expected dir type, got %s", entries[1].Type) + } +} + +func TestGetAllFilesInPath_File(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/owner/repo/contents/README.md" { + // Gitea returns 404 for contents API on files (it's not a dir) + http.NotFound(w, r) + return + } + if r.URL.Path == "/api/v1/repos/owner/repo/raw/README.md" { + fmt.Fprintf(w, "# Hello") + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + files, err := client.GetAllFilesInPath("owner", "repo", "README.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + if files["README.md"] != "# Hello" { + t.Errorf("unexpected content: %q", files["README.md"]) + } +} diff --git a/llm/client.go b/llm/client.go index 5dd62bc..1f3e580 100644 --- a/llm/client.go +++ b/llm/client.go @@ -14,6 +14,7 @@ type Client struct { BaseURL string APIKey string Model string + Temperature float64 HTTP *http.Client } @@ -27,6 +28,12 @@ func NewClient(baseURL, apiKey, model string) *Client { } } +// WithTemperature sets the temperature for LLM requests (0 = omit, uses server default). +func (c *Client) WithTemperature(t float64) *Client { + c.Temperature = t + return c +} + // Message represents a chat message. type Message struct { Role string `json:"role"` @@ -37,7 +44,7 @@ type Message struct { type ChatRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` - Temperature float64 `json:"temperature"` + Temperature float64 `json:"temperature,omitempty"` } // ChatResponse is the response from the API. @@ -53,8 +60,9 @@ type ChatResponse struct { func (c *Client) Complete(messages []Message) (string, error) { reqBody := ChatRequest{ Model: c.Model, + Temperature: c.Temperature, Messages: messages, - Temperature: 0.1, + } data, err := json.Marshal(reqBody) diff --git a/llm/client_test.go b/llm/client_test.go index 278668d..2487e4a 100644 --- a/llm/client_test.go +++ b/llm/client_test.go @@ -108,3 +108,80 @@ func TestComplete_ServerDown(t *testing.T) { t.Fatal("expected error for connection refused, got nil") } } + +func TestWithTemperature(t *testing.T) { + client := NewClient("http://example.com", "key", "model") + if client.Temperature != 0 { + t.Errorf("expected initial temperature 0, got %f", client.Temperature) + } + + result := client.WithTemperature(0.7) + if result != client { + t.Error("WithTemperature should return the same client for chaining") + } + if client.Temperature != 0.7 { + t.Errorf("expected temperature 0.7, got %f", client.Temperature) + } +} + +func TestComplete_TemperatureOmittedWhenZero(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + if _, exists := req["temperature"]; exists { + t.Error("temperature should be omitted when zero (server default)") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ChatResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{{Message: struct { + Content string `json:"content"` + }{Content: "ok"}}}, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "key", "model") + _, err := client.Complete([]Message{{Role: "user", Content: "Hi"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestComplete_TemperatureIncludedWhenSet(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + temp, exists := req["temperature"] + if !exists { + t.Error("temperature should be included when set") + } + if temp != 0.7 { + t.Errorf("expected temperature 0.7, got %v", temp) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ChatResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{{Message: struct { + Content string `json:"content"` + }{Content: "ok"}}}, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "key", "model").WithTemperature(0.7) + _, err := client.Complete([]Message{{Role: "user", Content: "Hi"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/review/prompt.go b/review/prompt.go index 0d44970..1011906 100644 --- a/review/prompt.go +++ b/review/prompt.go @@ -6,10 +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("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") @@ -36,15 +40,19 @@ func BuildSystemPrompt(conventions string) string { 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 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)) @@ -63,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") + } +}