#!/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. # Always save session to /tmp/pi-session so we can extract tool calls. # Capture stderr for error diagnostics. mkdir -p /tmp/pi-session pi \ ${PROVIDER_FLAG} \ --model "${PI_MODEL}" \ --tools "${PI_TOOLS}" \ --session-dir /tmp/pi-session \ -p "${PROMPT}" \ > /tmp/pi-review.md 2>/tmp/pi-agent.log if [ ! -s /tmp/pi-review.md ]; then echo "::error::Pi generated no output" echo "Agent log (last 30 lines):" tail -30 /tmp/pi-agent.log exit 1 fi # If debug mode, extract tool calls from session and append to review if [ "${PI_DEBUG}" = "true" ]; then SESSION_FILE=$(find /tmp/pi-session -name '*.jsonl' -type f 2>/dev/null | head -1) TOOL_LOG="" if [ -n "${SESSION_FILE}" ]; then # Extract tool-use entries: each line is a JSON object. # Tool calls appear as messages with tool_use/function_call content. TOOL_LOG=$(node -e " const fs = require('fs'); const lines = fs.readFileSync('${SESSION_FILE}', 'utf8').trim().split('\n'); const entries = []; for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === 'message') { const msg = entry.message; // Model requesting tool use if (msg.role === 'assistant' && msg.content) { const parts = Array.isArray(msg.content) ? msg.content : [msg.content]; for (const part of parts) { if (typeof part === 'object' && part.type === 'tool_use') { const input = part.input || {}; const args = Object.entries(input).map(([k,v]) => k + '=' + JSON.stringify(v)).join(' '); entries.push('[tool:' + part.name + '] ' + args); } } } } } catch {} } console.log(entries.join('\n')); " 2>/dev/null || echo "Could not parse session file") fi if [ -z "${TOOL_LOG}" ]; then TOOL_LOG="(no tool calls found — agent may have answered from the diff alone)" fi cat >> /tmp/pi-review.md << LOGEOF ---
🔍 Agent Tool Calls (${PI_MODEL}) \`\`\` ${TOOL_LOG} \`\`\`
LOGEOF echo "Debug: tool log appended to review" 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::"