From 0568a84aa9ec7237d99b844d372561a2f233b8aa Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 10:36:23 -0700 Subject: [PATCH 01/13] ci: add release workflow + install script - Release workflow: builds linux/darwin amd64/arm64 on tag push - Injects version via -ldflags - Creates Gitea release with binary assets + checksums - install.sh: curl-pipe-bash installer from latest release - Version variable in main.go for -version flag support --- .gitea/workflows/ci.yml | 7 ++--- .gitea/workflows/release.yml | 59 ++++++++++++++++++++++++++++++++++++ cmd/review-bot/main.go | 3 ++ install.sh | 35 +++++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100755 install.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e0a5214..e9b8c87 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,4 +1,5 @@ name: CI + on: push: branches: [main] @@ -12,7 +13,7 @@ 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 @@ -25,7 +26,7 @@ jobs: - 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 env: @@ -37,7 +38,6 @@ jobs: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_MODEL: "anthropic--claude-4.6-sonnet" CONVENTIONS_FILE: "CONVENTIONS.md" - REVIEWER_NAME: "Sonnet" run: ./review-bot - name: Run GPT Review env: @@ -49,5 +49,4 @@ jobs: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_MODEL: "sap-ai-opus-latest-openai/gpt-5" CONVENTIONS_FILE: "CONVENTIONS.md" - REVIEWER_NAME: "GPT" run: ./review-bot diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..b34b892 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,59 @@ +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 + RELEASE_ID=$(curl -sSf -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}" \ + | jq -r '.id') + + # Upload each asset + for file in dist/*; do + filename=$(basename "$file") + 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 $(ls dist/* | wc -l) assets" diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 3fcc3c8..a2b26ef 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -1,5 +1,6 @@ package main + import ( "flag" "fmt" @@ -13,6 +14,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") diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8ee18ad --- /dev/null +++ b/install.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Install review-bot from latest Gitea release +# Usage: curl -sSfL https://gitea.weiker.me/rodin/review-bot/raw/branch/main/install.sh | bash + +set -euo pipefail + +GITEA_URL="${GITEA_URL:-https://gitea.weiker.me}" +REPO="rodin/review-bot" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac + +BINARY="review-bot-${OS}-${ARCH}" + +# Get latest release tag +LATEST=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4) + +if [ -z "$LATEST" ]; then + echo "Failed to determine latest release" >&2 + exit 1 +fi + +echo "Installing review-bot ${LATEST} (${OS}/${ARCH})..." + +DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST}/${BINARY}" +curl -sSfL -o "${INSTALL_DIR}/review-bot" "$DOWNLOAD_URL" +chmod +x "${INSTALL_DIR}/review-bot" + +echo "Installed review-bot ${LATEST} to ${INSTALL_DIR}/review-bot" From d62e8ee4f07e3b52ceb3b85c95cb2de0d566f19c Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 10:40:39 -0700 Subject: [PATCH 02/13] ci: retrigger after adding secrets From 99916fe24a8b7f8768075eb6c344ddafcca2ff1c Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:02:27 -0700 Subject: [PATCH 03/13] fix(ci): use GPT models available via HAI proxy HAI proxy serves Anthropic on a different path (/anthropic/v1) than OpenAI (/openai/v1). Until review-bot supports multiple base URLs, use GPT-5 and GPT-5-mini for both review slots. --- .gitea/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e9b8c87..d0ad8b0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: REVIEWER_TOKEN: ${{ secrets.SONNET_REVIEW_TOKEN }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - LLM_MODEL: "anthropic--claude-4.6-sonnet" + LLM_MODEL: "gpt-5" CONVENTIONS_FILE: "CONVENTIONS.md" run: ./review-bot - name: Run GPT Review @@ -47,6 +47,6 @@ jobs: 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" + LLM_MODEL: "gpt-5-mini" CONVENTIONS_FILE: "CONVENTIONS.md" run: ./review-bot From b6277216f76d8f256aeaf91efa666526bf77290d Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:15:08 -0700 Subject: [PATCH 04/13] fix: remove hardcoded temperature (unsupported by GPT-5) GPT-5 via SAP AI Core only supports temperature=1 (default). Remove the hardcoded 0.1 and use omitempty so the field is not sent. --- llm/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/llm/client.go b/llm/client.go index 5dd62bc..a14a9bf 100644 --- a/llm/client.go +++ b/llm/client.go @@ -37,7 +37,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. @@ -54,7 +54,7 @@ func (c *Client) Complete(messages []Message) (string, error) { reqBody := ChatRequest{ Model: c.Model, Messages: messages, - Temperature: 0.1, + } data, err := json.Marshal(reqBody) From 4b3cac66c358648ff869ad70052ff1bd5e4144c6 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:22:31 -0700 Subject: [PATCH 05/13] fix: address review findings - install.sh: verify SHA-256 checksum before installing binary - install.sh: fallback to ~/.local/bin if /usr/local/bin not writable - install.sh: use sed instead of grep for POSIX-safe JSON parsing - release.yml: remove jq dependency, parse release ID with sed - llm: make temperature configurable via --llm-temperature / LLM_TEMPERATURE - llm: add WithTemperature builder method on Client - llm: omit temperature from request when zero (uses server default) --- .gitea/workflows/release.yml | 20 +++++++++++---- cmd/review-bot/main.go | 14 +++++++++++ install.sh | 48 +++++++++++++++++++++++++++++++++--- llm/client.go | 9 +++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b34b892..944bd02 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -38,17 +38,27 @@ jobs: GITEA_URL="${{ github.server_url }}" REPO="${{ github.repository }}" - # Create release - RELEASE_ID=$(curl -sSf -X POST \ + # Create release (parse ID without jq using grep/sed) + RESPONSE=$(curl -sSf -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}" \ - | jq -r '.id') + -d "{\"tag_name\": \"${VERSION}\", \"name\": \"${VERSION}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}") + + RELEASE_ID=$(echo "$RESPONSE" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1) + + if [ -z "$RELEASE_ID" ]; then + echo "Failed to create release" >&2 + echo "$RESPONSE" >&2 + exit 1 + fi + + echo "Created 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" \ @@ -56,4 +66,4 @@ jobs: --data-binary "@${file}" done - echo "Release ${VERSION} created with $(ls dist/* | wc -l) assets" + echo "Release ${VERSION} created with assets" diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index a2b26ef..e75c3ac 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -28,6 +28,7 @@ func main() { 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)") 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() @@ -55,6 +56,9 @@ func main() { // Initialize clients giteaClient := gitea.NewClient(*giteaURL, *reviewerToken) llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel) + if *llmTemp > 0 { + llmClient.WithTemperature(*llmTemp) + } log.Printf("Reviewing PR #%d on %s/%s", prNumber, owner, repoName) @@ -169,3 +173,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/install.sh b/install.sh index 8ee18ad..20e9bdc 100755 --- a/install.sh +++ b/install.sh @@ -6,7 +6,18 @@ set -euo pipefail GITEA_URL="${GITEA_URL:-https://gitea.weiker.me}" REPO="rodin/review-bot" -INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +INSTALL_DIR="${INSTALL_DIR:-}" + +# Determine install directory with fallback +if [ -z "$INSTALL_DIR" ]; then + if [ -w /usr/local/bin ]; then + INSTALL_DIR="/usr/local/bin" + else + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" + echo "Note: Installing to $INSTALL_DIR (add to PATH if needed)" + fi +fi OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) @@ -18,8 +29,8 @@ esac BINARY="review-bot-${OS}-${ARCH}" -# Get latest release tag -LATEST=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4) +# Get latest release tag (POSIX-safe parsing without jq) +LATEST=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' | head -1) if [ -z "$LATEST" ]; then echo "Failed to determine latest release" >&2 @@ -29,7 +40,36 @@ fi echo "Installing review-bot ${LATEST} (${OS}/${ARCH})..." DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST}/${BINARY}" -curl -sSfL -o "${INSTALL_DIR}/review-bot" "$DOWNLOAD_URL" +CHECKSUM_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST}/checksums.txt" + +# Download binary and checksums +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +curl -sSfL -o "${TMPDIR}/${BINARY}" "$DOWNLOAD_URL" +curl -sSfL -o "${TMPDIR}/checksums.txt" "$CHECKSUM_URL" + +# Verify checksum +cd "$TMPDIR" +EXPECTED=$(grep "${BINARY}" checksums.txt | awk '{print $1}') +ACTUAL=$(sha256sum "${BINARY}" | awk '{print $1}') + +if [ -z "$EXPECTED" ]; then + echo "Error: no checksum found for ${BINARY} in checksums.txt" >&2 + exit 1 +fi + +if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "Error: checksum mismatch!" >&2 + echo " Expected: $EXPECTED" >&2 + echo " Actual: $ACTUAL" >&2 + exit 1 +fi + +echo "Checksum verified ✓" + +# Install +cp "${TMPDIR}/${BINARY}" "${INSTALL_DIR}/review-bot" chmod +x "${INSTALL_DIR}/review-bot" echo "Installed review-bot ${LATEST} to ${INSTALL_DIR}/review-bot" diff --git a/llm/client.go b/llm/client.go index a14a9bf..a6bbccb 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,13 @@ func NewClient(baseURL, apiKey, model string) *Client { } } +// WithTemperature sets the temperature for LLM requests. +// If not set (zero value), the server default is used. +func (c *Client) WithTemperature(t float64) *Client { + c.Temperature = t + return c +} + // Message represents a chat message. type Message struct { Role string `json:"role"` @@ -53,6 +61,7 @@ type ChatResponse struct { func (c *Client) Complete(messages []Message) (string, error) { reqBody := ChatRequest{ Model: c.Model, + Temperature: c.Temperature, Messages: messages, } From c458587cfc7c51ad845d6c994130c3c261641c9b Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:32:15 -0700 Subject: [PATCH 06/13] feat: add composite action for clean distribution - .gitea/actions/review/action.yml: composite action with caching Consumers just use: uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 No Go toolchain needed, binary cached by version tag. - Remove install.sh (replaced by composite action) - CI workflow: use matrix strategy to parallelize reviews - Self-review still builds from source (pre-release) --- .gitea/actions/review/action.yml | 103 +++++++++++++++++++++++++++++++ .gitea/workflows/ci.yml | 27 ++++---- install.sh | 75 ---------------------- 3 files changed, 116 insertions(+), 89 deletions(-) create mode 100644 .gitea/actions/review/action.yml delete mode 100755 install.sh diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml new file mode 100644 index 0000000..d69428a --- /dev/null +++ b/.gitea/actions/review/action.yml @@ -0,0 +1,103 @@ +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: '' + 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 }}" + if [ "${{ inputs.version }}" = "latest" ]; then + VERSION=$(curl -sSf "${GITEA_URL}/api/v1/repos/rodin/review-bot/releases?limit=1" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' | head -1) + 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: /usr/local/bin/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 }}" + VERSION="${{ steps.version.outputs.version }}" + curl -sSfL "${GITEA_URL}/rodin/review-bot/releases/download/${VERSION}/review-bot-linux-amd64" -o /usr/local/bin/review-bot + chmod +x /usr/local/bin/review-bot + + - 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 }} + LLM_TEMPERATURE: ${{ inputs.temperature }} + run: | + ARGS="" + if [ "${{ inputs.dry-run }}" = "true" ]; then + ARGS="--dry-run" + fi + review-bot $ARGS diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d0ad8b0..c29b60d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,35 +18,34 @@ jobs: - 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' - 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: "gpt-5" - CONVENTIONS_FILE: "CONVENTIONS.md" - 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: "gpt-5-mini" + LLM_MODEL: ${{ matrix.model }} CONVENTIONS_FILE: "CONVENTIONS.md" run: ./review-bot diff --git a/install.sh b/install.sh deleted file mode 100755 index 20e9bdc..0000000 --- a/install.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash -# Install review-bot from latest Gitea release -# Usage: curl -sSfL https://gitea.weiker.me/rodin/review-bot/raw/branch/main/install.sh | bash - -set -euo pipefail - -GITEA_URL="${GITEA_URL:-https://gitea.weiker.me}" -REPO="rodin/review-bot" -INSTALL_DIR="${INSTALL_DIR:-}" - -# Determine install directory with fallback -if [ -z "$INSTALL_DIR" ]; then - if [ -w /usr/local/bin ]; then - INSTALL_DIR="/usr/local/bin" - else - INSTALL_DIR="${HOME}/.local/bin" - mkdir -p "$INSTALL_DIR" - echo "Note: Installing to $INSTALL_DIR (add to PATH if needed)" - fi -fi - -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) -case "$ARCH" in - x86_64) ARCH="amd64" ;; - aarch64|arm64) ARCH="arm64" ;; - *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; -esac - -BINARY="review-bot-${OS}-${ARCH}" - -# Get latest release tag (POSIX-safe parsing without jq) -LATEST=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' | head -1) - -if [ -z "$LATEST" ]; then - echo "Failed to determine latest release" >&2 - exit 1 -fi - -echo "Installing review-bot ${LATEST} (${OS}/${ARCH})..." - -DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST}/${BINARY}" -CHECKSUM_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST}/checksums.txt" - -# Download binary and checksums -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT - -curl -sSfL -o "${TMPDIR}/${BINARY}" "$DOWNLOAD_URL" -curl -sSfL -o "${TMPDIR}/checksums.txt" "$CHECKSUM_URL" - -# Verify checksum -cd "$TMPDIR" -EXPECTED=$(grep "${BINARY}" checksums.txt | awk '{print $1}') -ACTUAL=$(sha256sum "${BINARY}" | awk '{print $1}') - -if [ -z "$EXPECTED" ]; then - echo "Error: no checksum found for ${BINARY} in checksums.txt" >&2 - exit 1 -fi - -if [ "$EXPECTED" != "$ACTUAL" ]; then - echo "Error: checksum mismatch!" >&2 - echo " Expected: $EXPECTED" >&2 - echo " Actual: $ACTUAL" >&2 - exit 1 -fi - -echo "Checksum verified ✓" - -# Install -cp "${TMPDIR}/${BINARY}" "${INSTALL_DIR}/review-bot" -chmod +x "${INSTALL_DIR}/review-bot" - -echo "Installed review-bot ${LATEST} to ${INSTALL_DIR}/review-bot" From 8d53b649ee35ca7553e090cacc71ded3b5297077 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:38:28 -0700 Subject: [PATCH 07/13] fix: address review findings (cache path, docs) - Composite action: cache to runner.temp instead of /usr/local/bin (avoids permission issues on runners) - Document that temperature=0 means server default (omitted from request) - Note: strconv import already exists (false positive from GPT-5) --- .gitea/actions/review/action.yml | 8 ++++---- llm/client.go | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index d69428a..61f75f7 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -70,7 +70,7 @@ runs: id: cache uses: actions/cache@v4 with: - path: /usr/local/bin/review-bot + path: ${{ runner.temp }}/review-bot key: review-bot-linux-amd64-${{ steps.version.outputs.version }} - name: Install review-bot @@ -79,8 +79,8 @@ runs: run: | GITEA_URL="${{ inputs.gitea-url || github.server_url }}" VERSION="${{ steps.version.outputs.version }}" - curl -sSfL "${GITEA_URL}/rodin/review-bot/releases/download/${VERSION}/review-bot-linux-amd64" -o /usr/local/bin/review-bot - chmod +x /usr/local/bin/review-bot + curl -sSfL "${GITEA_URL}/rodin/review-bot/releases/download/${VERSION}/review-bot-linux-amd64" -o ${{ runner.temp }}/review-bot + chmod +x ${{ runner.temp }}/review-bot - name: Run review shell: bash @@ -100,4 +100,4 @@ runs: if [ "${{ inputs.dry-run }}" = "true" ]; then ARGS="--dry-run" fi - review-bot $ARGS + ${{ runner.temp }}/review-bot $ARGS diff --git a/llm/client.go b/llm/client.go index a6bbccb..5af281b 100644 --- a/llm/client.go +++ b/llm/client.go @@ -29,6 +29,8 @@ func NewClient(baseURL, apiKey, model string) *Client { } // WithTemperature sets the temperature for LLM requests. +// A value of 0 (the zero value) means the field is omitted from the request, +// causing the server to use its default temperature. // If not set (zero value), the server default is used. func (c *Client) WithTemperature(t float64) *Client { c.Temperature = t From 59fbd38837e1943f710e9594dd6b1c47cf923a84 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:40:15 -0700 Subject: [PATCH 08/13] fix: address all remaining review findings - Add temperature range validation (must be 0-2, fatal on invalid) - release.yml: use python3 for robust JSON parsing instead of sed - Composite action: add header comment confirming Gitea Actions compat - All findings from review #385 addressed --- .gitea/actions/review/action.yml | 3 +++ .gitea/workflows/release.yml | 5 +++-- cmd/review-bot/main.go | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index 61f75f7..d0d9e3b 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -1,3 +1,6 @@ +# 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' diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 944bd02..9a320ec 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -38,14 +38,15 @@ jobs: GITEA_URL="${{ github.server_url }}" REPO="${{ github.repository }}" - # Create release (parse ID without jq using grep/sed) + # Create release RESPONSE=$(curl -sSf -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}") - RELEASE_ID=$(echo "$RESPONSE" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1) + # Parse release ID using Python (robust JSON parsing) + RELEASE_ID=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])") if [ -z "$RELEASE_ID" ]; then echo "Failed to create release" >&2 diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index e75c3ac..62e0e2d 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -56,6 +56,9 @@ llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", // 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) } From 46c63ed121307d9662daea8719c195043fd462c4 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 11:58:21 -0700 Subject: [PATCH 09/13] fix: address all review findings (zero remaining) Tests: - Add WithTemperature tests (builder method, chaining, zero omission) - Add temperature serialization tests (omitted when 0, included when set) Composite action: - Use python3 for robust JSON version parsing (replaces sed) - Verify SHA-256 checksum before executing downloaded binary - Wire up repo input (no longer hardcodes rodin/review-bot) Release workflow: - Handle 409 conflict (existing release for tag) - Use file-based JSON parsing for reliability Code: - Tighten WithTemperature doc comment (single clear line) - Fix flag alignment (missing tab on llmTemp declaration) --- .gitea/actions/review/action.yml | 32 +++++++++++-- .gitea/workflows/release.yml | 29 ++++++++---- cmd/review-bot/main.go | 2 +- llm/client.go | 5 +-- llm/client_test.go | 77 ++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 17 deletions(-) diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index d0d9e3b..f218a02 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -58,8 +58,10 @@ runs: 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/rodin/review-bot/releases?limit=1" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' | head -1) + 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 @@ -81,9 +83,33 @@ runs: shell: bash run: | GITEA_URL="${{ inputs.gitea-url || github.server_url }}" + REPO="${{ inputs.repo || 'rodin/review-bot' }}" VERSION="${{ steps.version.outputs.version }}" - curl -sSfL "${GITEA_URL}/rodin/review-bot/releases/download/${VERSION}/review-bot-linux-amd64" -o ${{ runner.temp }}/review-bot - chmod +x ${{ runner.temp }}/review-bot + 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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9a320ec..b268f29 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -38,23 +38,34 @@ jobs: GITEA_URL="${{ github.server_url }}" REPO="${{ github.repository }}" - # Create release - RESPONSE=$(curl -sSf -X POST \ + # 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}") - # Parse release ID using Python (robust JSON parsing) - RELEASE_ID=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])") - - if [ -z "$RELEASE_ID" ]; then - echo "Failed to create release" >&2 - echo "$RESPONSE" >&2 + 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 - echo "Created release ID: ${RELEASE_ID}" + # 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 diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 62e0e2d..48b6742 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -28,7 +28,7 @@ func main() { 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)") 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)") + llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)") flag.Parse() diff --git a/llm/client.go b/llm/client.go index 5af281b..1f3e580 100644 --- a/llm/client.go +++ b/llm/client.go @@ -28,10 +28,7 @@ func NewClient(baseURL, apiKey, model string) *Client { } } -// WithTemperature sets the temperature for LLM requests. -// A value of 0 (the zero value) means the field is omitted from the request, -// causing the server to use its default temperature. -// If not set (zero value), the server default is used. +// WithTemperature sets the temperature for LLM requests (0 = omit, uses server default). func (c *Client) WithTemperature(t float64) *Client { c.Temperature = t return c 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) + } +} From c76362af953b9294712b641220adc94a307cef04 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 12:00:27 -0700 Subject: [PATCH 10/13] fix: prevent false-positive missing-import findings The LLM was treating the diff as complete file context and flagging "missing imports" for symbols that exist in unchanged portions of the file. Added explicit instructions to the system prompt: - Clarify that the diff is partial; unchanged code already exists - Explicitly instruct: do not flag missing imports/types unless the diff introduces a new usage without a corresponding addition - Add rule: never flag undefined symbols that could exist in the unchanged portions --- review/prompt.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/review/prompt.go b/review/prompt.go index 0d44970..e530c69 100644 --- a/review/prompt.go +++ b/review/prompt.go @@ -10,6 +10,11 @@ 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("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("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") @@ -35,6 +40,7 @@ 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 conventions != "" { sb.WriteString(fmt.Sprintf("\n\nThe repository has the following coding conventions that should be respected:\n\n%s\n", conventions)) From e234dca4745de693edcfe457a699f5076f45a6c9 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 12:11:49 -0700 Subject: [PATCH 11/13] 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. --- .gitea/actions/review/action.yml | 10 ++++ cmd/review-bot/main.go | 81 ++++++++++++++++++++++++++++---- gitea/client.go | 37 +++++++++++++-- gitea/client_test.go | 45 ++++++++++++++++++ review/prompt.go | 28 +++++++---- review/prompt_test.go | 53 ++++++++++++++++++--- 6 files changed, 226 insertions(+), 28 deletions(-) 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") + } +} From 56f5abda3cad82f98269f266bd1e45249f918af9 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 12:14:19 -0700 Subject: [PATCH 12/13] feat: multi-repo patterns + directory recursion patterns-repo now accepts a comma-separated list of repos: PATTERNS_REPO="rodin/elixir-patterns,rodin/phoenix-conventions" patterns-files accepts files AND directories: PATTERNS_FILES="README.md,docs/" When a path is a directory, all files within it are fetched recursively via the Gitea contents API. Only .md, .txt, .yml, and .yaml files are included as pattern content. New API methods: - ListContents: list files/dirs at a path via contents API - GetAllFilesInPath: recursively fetch all file contents This allows a single review action to pull idioms from multiple pattern repos (e.g. elixir-patterns + phoenix-conventions) and include entire directories of documentation as review criteria. --- cmd/review-bot/main.go | 60 +++++++++++++++++++++++++++++++----------- gitea/client.go | 60 ++++++++++++++++++++++++++++++++++++++++++ gitea/client_test.go | 55 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 470cc0f..8715b6e 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -184,31 +184,61 @@ func fetchFileContext(client *gitea.Client, owner, repo, ref string, files []git return sb.String() } -// fetchPatterns fetches pattern files from an external repo. +// 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 { - 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 == "" { + + repos := strings.Split(patternsRepo, ",") + paths := strings.Split(patternsFiles, ",") + + for _, repoRef := range repos { + repoRef = strings.TrimSpace(repoRef) + if repoRef == "" { 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) + parts := strings.SplitN(repoRef, "/", 2) + if len(parts) != 2 { + log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", repoRef) continue } - sb.WriteString(fmt.Sprintf("### %s/%s\n\n%s\n\n", patternsRepo, filepath, content)) + 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 { diff --git a/gitea/client.go b/gitea/client.go index 84caf8d..71607f4 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -177,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 600ff5c..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" @@ -238,3 +239,57 @@ func TestGetFileContentRef(t *testing.T) { 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"]) + } +} From f77ea171c3c7d4f84e917982ec7e8b462806e9e0 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 12:15:25 -0700 Subject: [PATCH 13/13] ci: add go-patterns as review reference Self-reviews now use rodin/go-patterns (README.md + docs/) as language idiom criteria alongside CONVENTIONS.md. --- .gitea/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c29b60d..6adf9d9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -48,4 +48,6 @@ jobs: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_MODEL: ${{ matrix.model }} CONVENTIONS_FILE: "CONVENTIONS.md" + PATTERNS_REPO: "rodin/go-patterns" + PATTERNS_FILES: "README.md,docs/" run: ./review-bot