commit 28b4b23550816533d5b60ff39fb894650359a9f2 Author: Markus Hofstetter Date: Mon May 18 22:09:46 2026 +0200 feat: initial pi-review Docker action Reusable Gitea/GitHub action that runs Pi coding agent for AI-powered code reviews on pull requests. - Docker image based on node:24-slim (112 packages) - Supports built-in providers (zai, anthropic, openai, deepseek, openrouter) and custom OpenAI-compatible endpoints - Generates git diff (excludes lockfiles/generated code by default) - Posts review as idempotent PR comment (updates existing on re-run) - Read-only tools only: agent investigates but never modifies code - 80KB default diff truncation to stay within LLM context windows - No curl/python3 dependency — uses Node.js for HTTP and JSON diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c99ae58 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,40 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +indent_size = 2 +indent_style = space +charset = utf-8 +end_of_line = lf +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.java] +# Mutige können die Ausnahme für .java entfernen :) +indent_size = 4 +indent_style = space + +[*.xml] +# automatisches Umbrechen/Trimmen kann die Semantik vom Inhalt ändern +max_line_length = off +trim_trailing_whitespace = false + +# Kompatibilität zu maven-dependency-plugin/maven-release-plugin... und damit zu jgitflow +[pom.xml] +indent_size = 4 +indent_style = space + +[*.md] +indent_size = 4 +max_line_length = off +trim_trailing_whitespace = false + +[*.{bat,ps1}] +end_of_line = crlf + +[api/src/test/resources/AerzteDocMergerTest/expected/*.txt] +trim_trailing_whitespace = false + +[api/src/test/resources/DocMergerTest/expected/*.txt] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a66138 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.DS_Store +*.log +.vfox diff --git a/.vfox.toml b/.vfox.toml new file mode 100644 index 0000000..a86d2ad --- /dev/null +++ b/.vfox.toml @@ -0,0 +1,2 @@ +[tools] +nodejs = "24.15.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54f792e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:24-slim + +# node:24-slim has ~88 packages (vs 413 in bookworm). +# We only add git for diffing. curl and python3 are replaced by Node.js. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Pi globally — baked into the image, no install at runtime +RUN npm install -g @earendil-works/pi-coding-agent + +# Copy action files into the image +COPY prompts/default.md /action/prompts/default.md +COPY scripts/review.sh /action/scripts/review.sh +COPY entrypoint.sh /action/entrypoint.sh + +RUN chmod +x /action/entrypoint.sh /action/scripts/review.sh + +# Disable Pi's startup network calls +ENV PI_OFFLINE=1 + +ENTRYPOINT ["/action/entrypoint.sh"] diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e5e4231 --- /dev/null +++ b/action.yml @@ -0,0 +1,43 @@ +name: "Pi Code Review" +description: "AI-powered code review using the Pi coding agent framework with any LLM provider" +author: "homa" + +inputs: + api_key: + description: "API key for the LLM provider" + required: true + provider: + description: "Pi provider name (zai, anthropic, openai, openrouter, deepseek) or 'custom' for models.json config" + required: false + default: "zai" + model: + description: "Model ID (e.g. glm-4.7, claude-sonnet-4-20250514, gpt-4o)" + required: false + default: "glm-5.1" + base_url: + description: "Custom API base URL (for OpenAI-compatible providers). Ignored for built-in providers." + required: false + default: "" + token: + description: "Git platform token for posting PR comments" + required: true + review_prompt: + description: "Path to a custom review prompt file (relative to calling repo root)" + required: false + default: "" + exclude_patterns: + description: "Space-separated glob patterns to exclude from the diff" + required: false + default: "*.lock package-lock.json yarn.lock pnpm-lock.yaml *.min.js *.min.css *.map" + tools: + description: "Comma-separated tools the agent can use (read-only recommended for CI)" + required: false + default: "read,grep,find,ls" + max_diff_bytes: + description: "Max diff size in bytes before truncation (0 = unlimited)" + required: false + default: "80000" + +runs: + using: "docker" + image: "Dockerfile" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..35336c8 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── Entrypoint for Docker action ──────────────────────────────────────────── +# Docker actions receive inputs as INPUT_ env vars. +# We map them to PI_* vars that review.sh expects, then run the review. +# ────────────────────────────────────────────────────────────────────────────── + +# Map Docker action inputs to review.sh env vars +export PI_API_KEY="${INPUT_API_KEY}" +export PI_PROVIDER="${INPUT_PROVIDER:-zai}" +export PI_MODEL="${INPUT_MODEL:-glm-5.1}" +export PI_BASE_URL="${INPUT_BASE_URL:-}" +export PI_TOOLS="${INPUT_TOOLS:-read,grep,find,ls}" +export PI_REVIEW_PROMPT="${INPUT_REVIEW_PROMPT:-}" +export PI_EXCLUDE="${INPUT_EXCLUDE_PATTERNS:-*.lock package-lock.json yarn.lock pnpm-lock.yaml *.min.js *.min.css *.map}" +export PI_MAX_DIFF="${INPUT_MAX_DIFF_BYTES:-80000}" +export PI_TOKEN="${INPUT_TOKEN}" + +# The calling repo is mounted at GITHUB_WORKSPACE by both GitHub and Gitea. +# cd into it so git commands work against the right repo. +cd "${GITHUB_WORKSPACE:-/github/workspace}" + +echo "Workspace: $(pwd)" +echo "Provider: ${PI_PROVIDER}" +echo "Model: ${PI_MODEL}" + +# Run the review +bash /action/scripts/review.sh diff --git a/package.json b/package.json new file mode 100644 index 0000000..da504fd --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "pi-review", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "^11.1.2", + "onFail": "download" + } + }, + "type": "module" +} diff --git a/prompts/default.md b/prompts/default.md new file mode 100644 index 0000000..31fb5ba --- /dev/null +++ b/prompts/default.md @@ -0,0 +1,25 @@ +You are a senior code reviewer. Review the code changes in this pull request. + +Process: +1. Read the git diff at /tmp/pi-diff.txt +2. Read any surrounding files needed for full context +3. Analyze the changes against the criteria below +4. Output a structured review + +Review criteria: +- Bugs and logic errors +- Security vulnerabilities (injection, auth bypass, data exposure) +- Error handling gaps (missing null checks, unhandled exceptions) +- Race conditions or concurrency issues +- Breaking changes to public APIs +- Maintainability Issues and Clean Code (DRY, Complexity, Leasts Surprise) + +Output format: +- Start with a one-line summary of what this PR does +- List findings grouped by severity: + - 🔴 **Critical**: Must fix before merge (bugs, security) + - 🟡 **Warning**: Should fix (logic gaps, missing error handling) + - 🟢 **Suggestion**: Nice to have (readability, minor improvements) +- End with a verdict: **Approve** or **Request Changes** +- Skip style-only and formatting comments +- If the PR looks good with no issues, say so and approve diff --git a/scripts/review.sh b/scripts/review.sh new file mode 100755 index 0000000..9237c99 --- /dev/null +++ b/scripts/review.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── Pi Code Review Action ─────────────────────────────────────────────────── +# Configures Pi, generates a diff, runs the agent in --print mode, and posts +# the review as a PR comment. Works with both Gitea and GitHub. +# +# Runs inside a Docker container. Pi is pre-installed in the image. +# The calling repo is at $GITHUB_WORKSPACE (set by the CI platform). +# ────────────────────────────────────────────────────────────────────────────── + +# ─── Phase 1: Configure Pi ──────────────────────────────────────────────────── +echo "::group::Configure Pi" +mkdir -p ~/.pi/agent + +AUTH_FILE=~/.pi/agent/auth.json + +# Map provider name to auth.json key +case "$PI_PROVIDER" in + zai) AUTH_KEY="zai" ;; + anthropic) AUTH_KEY="anthropic" ;; + openai) AUTH_KEY="openai" ;; + deepseek) AUTH_KEY="deepseek" ;; + openrouter) AUTH_KEY="openrouter" ;; + *) AUTH_KEY="" ;; +esac + +if [ -n "$AUTH_KEY" ]; then + # Built-in provider: write auth.json + cat > "$AUTH_FILE" << EOF +{ + "${AUTH_KEY}": { "type": "api_key", "key": "${PI_API_KEY}" } +} +EOF + PROVIDER_FLAG="--provider ${PI_PROVIDER}" +else + # Custom provider: write models.json + if [ -z "$PI_BASE_URL" ]; then + echo "::error::Custom provider requires base_url input" + exit 1 + fi + cat > ~/.pi/agent/models.json << EOF +{ + "providers": { + "custom-review": { + "baseUrl": "${PI_BASE_URL}", + "api": "openai-completions", + "apiKey": "${PI_API_KEY}", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "models": [ + { + "id": "${PI_MODEL}", + "reasoning": false, + "input": ["text"], + "contextWindow": 128000, + "maxTokens": 16384 + } + ] + } + } +} +EOF + PROVIDER_FLAG="--provider custom-review" +fi + +chmod 600 "$AUTH_FILE" +echo "Configured provider: ${PI_PROVIDER}" +echo "::endgroup::" + +# ─── Phase 2: Generate diff ─────────────────────────────────────────────────── +echo "::group::Generate diff" + +# Ensure we have full history (runner may have done a shallow checkout) +git fetch --unshallow --filter=blob:none origin 2>/dev/null || true +git fetch origin main 2>/dev/null || git fetch origin master 2>/dev/null || true + +BASE="origin/main" +if ! git rev-parse --verify "$BASE" >/dev/null 2>&1; then + BASE="origin/master" +fi + +# Build exclude pathspecs +EXCLUDE_ARGS="" +for pattern in $PI_EXCLUDE; do + EXCLUDE_ARGS="$EXCLUDE_ARGS ':!$pattern'" +done + +eval "git diff ${BASE}...HEAD ${EXCLUDE_ARGS}" > /tmp/pi-diff.txt 2>/dev/null || true + +# Truncate if needed +if [ "${PI_MAX_DIFF}" -gt 0 ]; then + head -c "${PI_MAX_DIFF}" /tmp/pi-diff.txt > /tmp/pi-diff-trunc.txt + mv /tmp/pi-diff-trunc.txt /tmp/pi-diff.txt +fi + +DIFF_SIZE=$(wc -c < /tmp/pi-diff.txt || echo 0) +echo "Diff size: ${DIFF_SIZE} bytes" +echo "::endgroup::" + +if [ "${DIFF_SIZE}" -eq 0 ]; then + echo "No changes to review. Skipping." + exit 0 +fi + +# ─── Phase 3: Run Pi ────────────────────────────────────────────────────────── +echo "::group::Run Pi review" + +# Prompt: custom (from calling repo) or default (baked into Docker image) +ACTION_DIR="/action" + +if [ -n "${PI_REVIEW_PROMPT}" ] && [ -f "${PI_REVIEW_PROMPT}" ]; then + PROMPT=$(cat "${PI_REVIEW_PROMPT}") + echo "Using custom prompt: ${PI_REVIEW_PROMPT}" +else + PROMPT=$(cat "${ACTION_DIR}/prompts/default.md") + echo "Using default prompt" +fi + +# Append diff instruction +PROMPT="${PROMPT} + +The git diff is at /tmp/pi-diff.txt. Start by reading it, then read any files you need for full context." + +# Run Pi in print mode (non-interactive, no session persistence) +pi --no-session \ + ${PROVIDER_FLAG} \ + --model "${PI_MODEL}" \ + --tools "${PI_TOOLS}" \ + -p "${PROMPT}" \ + > /tmp/pi-review.md 2>/dev/null + +if [ ! -s /tmp/pi-review.md ]; then + echo "::error::Pi generated no output" + exit 1 +fi + +echo "Review generated successfully." +echo "::endgroup::" + +# ─── Phase 4: Post comment ──────────────────────────────────────────────────── +echo "::group::Post review comment" + +# Detect Gitea vs GitHub +if [ -n "${GITEA_SERVER_URL:-}" ]; then + API_BASE="${GITEA_SERVER_URL}/api/v1" + PR_NUMBER="${GITEA_EVENT_PULL_REQUEST_NUMBER:-}" + REPO="${GITEA_REPOSITORY:-}" +else + API_BASE="${GITHUB_API_URL:-https://api.github.com}" + PR_NUMBER="${GITHUB_EVENT_PULL_REQUEST_NUMBER:-}" + REPO="${GITHUB_REPOSITORY:-}" +fi + +if [ -z "$PR_NUMBER" ]; then + echo "Not a pull request event. Review written to /tmp/pi-review.md only." + echo "::endgroup::" + exit 0 +fi + +MARKER="" + +# Use Node.js for all HTTP and JSON — avoids needing curl in the image. +# Node's built-in https module is already available since we need it for Pi. +node -e " +const http = require('http'); +const https = require('https'); +const fs = require('fs'); + +const apiBase = '${API_BASE}'; +const repo = '${REPO}'; +const prNumber = '${PR_NUMBER}'; +const token = '${PI_TOKEN}'; +const marker = '${MARKER}'; + +const review = fs.readFileSync('/tmp/pi-review.md', 'utf8'); +const body = '## Pi Code Review\n\n' + review + '\n\n' + marker; + +function apiRequest(method, path, data) { + return new Promise((resolve, reject) => { + const url = new URL(apiBase + path); + const transport = url.protocol === 'http:' ? http : https; + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'http:' ? 80 : 443), + path: url.pathname + url.search, + method, + headers: { + 'Authorization': 'token ' + token, + 'Content-Type': 'application/json', + }, + }; + const req = transport.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(body)); } catch { resolve(null); } + } else { + console.error('API error ' + res.statusCode + ': ' + body.slice(0, 200)); + resolve(null); + } + }); + }); + req.on('error', (e) => { console.error('Request failed:', e.message); resolve(null); }); + if (data) req.write(JSON.stringify(data)); + req.end(); + }); +} + +async function main() { + // Find existing review comment + const comments = await apiRequest('GET', '/repos/' + repo + '/issues/' + prNumber + '/comments'); + let existingId = null; + if (Array.isArray(comments)) { + for (const c of comments) { + if (c.body && c.body.includes(marker)) { + existingId = c.id; + break; + } + } + } + + const payload = { body }; + + if (existingId) { + console.log('Updating existing comment ' + existingId); + await apiRequest('PATCH', '/repos/' + repo + '/issues/comments/' + existingId, payload); + console.log('Comment updated.'); + } else { + console.log('Creating new review comment'); + await apiRequest('POST', '/repos/' + repo + '/issues/' + prNumber + '/comments', payload); + console.log('Comment posted.'); + } +} + +main().catch((e) => { console.error(e.message); process.exit(1); }); +" + +echo "::endgroup::"