#!/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: Fetch diff via API ─────────────────────────────────────────────── echo "Generate diff" # Git operations inside the Docker container have no auth credentials # (actions/checkout@v5 stores them in $RUNNER_TEMP, which isn't mounted). # Instead, we get the diff directly from the Gitea/GitHub API using the token # we already have for posting comments. # Detect platform and resolve PR info if [ -n "${GITEA_SERVER_URL:-}" ]; then API_BASE="${GITEA_SERVER_URL}/api/v1" PR_NUMBER="${GITEA_EVENT_PULL_REQUEST_NUMBER:-}" REPO="${GITEA_REPOSITORY:-}" echo "Platform: Gitea (${GITEA_SERVER_URL})" else API_BASE="${GITHUB_API_URL:-https://api.github.com}" PR_NUMBER="${GITHUB_EVENT_PULL_REQUEST_NUMBER:-}" REPO="${GITHUB_REPOSITORY:-}" echo "Platform: GitHub" fi echo "Repo: ${REPO}, PR: ${PR_NUMBER}" if [ -z "$PR_NUMBER" ]; then echo "Not a pull request event. Skipping review." exit 0 fi # Fetch diff via API — works regardless of git auth inside the container. # Gitea: GET /repos/{owner}/{repo}/pulls/{index}.diff # GitHub: GET /repos/{owner}/{repo}/pulls/{index} (Accept: application/diff) node -e " const http = require('http'); const https = require('https'); const apiBase = '${API_BASE}'; const repo = '${REPO}'; const prNumber = '${PR_NUMBER}'; const token = '${PI_TOKEN}'; const maxBytes = ${PI_MAX_DIFF:-80000}; function fetchDiff() { return new Promise((resolve, reject) => { // Try Gitea diff endpoint first const giteaPath = '/repos/' + repo + '/pulls/' + prNumber + '.diff'; const githubPath = '/repos/' + repo + '/pulls/' + prNumber; const url = new URL(apiBase + giteaPath); const transport = url.protocol === 'http:' ? http : https; const options = { hostname: url.hostname, port: url.port || (url.protocol === 'http:' ? 80 : 443), path: url.pathname, method: 'GET', headers: { 'Authorization': 'token ' + token, 'Accept': 'text/plain', }, }; const req = transport.request(options, (res) => { if (res.statusCode === 404 && apiBase.indexOf('github.com') !== -1) { // Fallback to GitHub diff format reject(new Error('GitHub fallback not implemented')); return; } if (res.statusCode < 200 || res.statusCode >= 300) { let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => { reject(new Error('API ' + res.statusCode + ': ' + body.slice(0, 200))); }); return; } let data = ''; let bytes = 0; res.on('data', (chunk) => { bytes += chunk.length; if (maxBytes > 0 && bytes <= maxBytes) { data += chunk; } }); res.on('end', () => { if (maxBytes > 0 && data.length >= maxBytes) { data = data.slice(0, maxBytes) + '\\n... (truncated at ' + maxBytes + ' bytes)'; } resolve(data); }); }); req.on('error', (e) => { reject(e); }); req.end(); }); } fetchDiff().then((diff) => { const fs = require('fs'); // Filter out excluded patterns (lockfiles, generated code, etc.) const excludePatterns = '${PI_EXCLUDE}'.split(' ').filter(Boolean); if (excludePatterns.length > 0) { const lines = diff.split('\\n'); const filtered = []; let skipFile = false; for (const line of lines) { if (line.startsWith('diff --git')) { skipFile = excludePatterns.some(p => { const glob = p.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*'); return new RegExp(glob).test(line); }); } if (!skipFile) filtered.push(line); } diff = filtered.join('\\n'); } if (maxBytes > 0 && diff.length > maxBytes) { diff = diff.slice(0, maxBytes) + '\\n... (truncated at ' + maxBytes + ' bytes)'; } fs.writeFileSync('/tmp/pi-diff.txt', diff); console.log('Diff fetched: ' + diff.length + ' bytes'); }).catch((e) => { console.error('Failed to fetch diff: ' + e.message); process.exit(1); }); " if [ ! -s /tmp/pi-diff.txt ]; 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::"