Files
pi-review/scripts/review.sh
Markus Hofstetter bb34a3f2ec fix: debug mode now extracts tool calls from Pi session file
Pi doesn't log tool calls to stderr in print mode. Instead, we save
the session (.jsonl) and parse it to extract every tool_use entry
showing which files were read, grep patterns used, etc.
2026-05-18 23:51:36 +02:00

305 lines
9.5 KiB
Bash
Executable File

#!/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
---
<details>
<summary>🔍 <strong>Agent Tool Calls</strong> (${PI_MODEL})</summary>
\`\`\`
${TOOL_LOG}
\`\`\`
</details>
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="<!-- pi-code-review -->"
# 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::"