Files
pi-review/scripts/review.sh
Markus Hofstetter f1a4958b34 fix: fetch diff via Gitea/GitHub API instead of git
Git operations inside the Docker container have no auth credentials
(actions/checkout@v5 stores them in $RUNNER_TEMP, not mounted).

Instead of fighting git auth, fetch the diff directly from the
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}.diff

This uses the same token already passed for posting comments.
No pre-fetch workflow step needed. No git required in the container.

Also filters excluded patterns (lockfiles, etc.) from the API diff.
2026-05-21 01:08:21 +02:00

402 lines
12 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: 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
---
<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::"