#!/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. # Gitea Actions mirrors GitHub Actions env vars ($GITHUB_*) and exposes the # event payload at $GITHUB_EVENT_PATH. There is no flat $GITEA_EVENT_* # variable for the PR number, so we parse it out of the event JSON. REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" # Detect Gitea vs GitHub. Gitea sets $GITHUB_SERVER_URL too, so the only # reliable signal is $GITEA_ACTIONS (or a non-github.com server URL). # # $PI_SERVER_URL (server_url input) overrides the auto-detected server URL. # Gitea reports its *external* hostname (e.g. git.example.com), which may not # be routable from inside the runner's container network. Setting server_url # to the internal address (e.g. http://server:3000) fixes API calls. SERVER_URL="${PI_SERVER_URL:-${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}}" if [ -n "${GITEA_ACTIONS:-}" ] || { [ -n "$SERVER_URL" ] && [ "${SERVER_URL#*github.com}" = "$SERVER_URL" ]; }; then API_BASE="${SERVER_URL}/api/v1" PLATFORM="gitea" echo "Platform: Gitea (${SERVER_URL})" else API_BASE="${GITHUB_API_URL:-https://api.github.com}" PLATFORM="github" echo "Platform: GitHub" fi # Resolve PR number from the event payload. PR_NUMBER="" if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "${GITHUB_EVENT_PATH}" ]; then PR_NUMBER=$(node -e " try { const e = require(process.env.GITHUB_EVENT_PATH); const n = (e.pull_request && e.pull_request.number) || e.number || ''; process.stdout.write(String(n || '')); } catch { process.stdout.write(''); } ") fi echo "Repo: ${REPO}, PR: ${PR_NUMBER}" echo "DEBUG: PLATFORM=${PLATFORM} API_BASE=${API_BASE} EVENT=${GITHUB_EVENT_PATH:-}" 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 platform = '${PLATFORM}'; const maxBytes = ${PI_MAX_DIFF:-80000}; function fetchDiff() { return new Promise((resolve, reject) => { // Gitea: GET {api}/repos/{owner}/{repo}/pulls/{n}.diff // GitHub: GET {api}/repos/{owner}/{repo}/pulls/{n} with Accept: application/vnd.github.v3.diff const path = platform === 'gitea' ? '/repos/' + repo + '/pulls/' + prNumber + '.diff' : '/repos/' + repo + '/pulls/' + prNumber; const url = new URL(apiBase + path); const transport = url.protocol === 'http:' ? http : https; const headers = { 'Authorization': 'token ' + token, 'User-Agent': 'pi-code-review', 'Accept': platform === 'gitea' ? 'text/plain' : 'application/vnd.github.v3.diff', }; const options = { hostname: url.hostname, port: url.port || (url.protocol === 'http:' ? 80 : 443), path: url.pathname, method: 'GET', headers, }; const req = transport.request(options, (res) => { if (res.statusCode < 200 || res.statusCode >= 300) { let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => { reject(new Error('API ' + res.statusCode + ' for ' + url.href + ': ' + body.slice(0, 200))); }); return; } let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { 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 echo "Debug mode: PI_DEBUG=${PI_DEBUG}" # If debug mode, extract tool calls from session and append to review if [ "${PI_DEBUG}" = "true" ]; then echo "Session dir contents:" find /tmp/pi-session -type f 2>/dev/null || echo " (none)" SESSION_FILE=$(find /tmp/pi-session -name '*.jsonl' -type f 2>/dev/null | head -1) echo "Session file: ${SESSION_FILE:-}" TOOL_LOG="" if [ -n "${SESSION_FILE}" ]; then # Extract tool-call entries from Pi session JSONL. # Pi stores assistant messages with content blocks of type "toolCall" # (NOT the Anthropic SDK's "tool_use"), using "arguments" (NOT "input"). # Tool results are separate entries with role "toolResult". 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') continue; const msg = entry.message; if (!msg || !msg.content) continue; const parts = Array.isArray(msg.content) ? msg.content : [msg.content]; for (const part of parts) { if (typeof part !== 'object') continue; if (part.type === 'toolCall') { const args = Object.entries(part.arguments || {}) .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" # API_BASE / REPO / PR_NUMBER were resolved in Phase 2. 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::"