From 28b4b23550816533d5b60ff39fb894650359a9f2 Mon Sep 17 00:00:00 2001 From: Markus Hofstetter Date: Mon, 18 May 2026 22:09:46 +0200 Subject: [PATCH] feat: initial pi-review Docker action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .editorconfig | 40 ++++++++ .gitattributes | 1 + .gitignore | 4 + .vfox.toml | 2 + Dockerfile | 22 +++++ action.yml | 43 ++++++++ entrypoint.sh | 29 ++++++ package.json | 20 ++++ prompts/default.md | 25 +++++ scripts/review.sh | 242 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 428 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vfox.toml create mode 100644 Dockerfile create mode 100644 action.yml create mode 100755 entrypoint.sh create mode 100644 package.json create mode 100644 prompts/default.md create mode 100755 scripts/review.sh 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::"