When debug: true, the review comment includes a collapsible section showing which files the agent read, grep patterns used, etc. Also prints agent log on failure for easier troubleshooting.
265 lines
8.1 KiB
Bash
Executable File
265 lines
8.1 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 (non-interactive, no session persistence)
|
|
# Capture stderr (tool call log) always — only show it when debug is on
|
|
pi --no-session \
|
|
${PROVIDER_FLAG} \
|
|
--model "${PI_MODEL}" \
|
|
--tools "${PI_TOOLS}" \
|
|
-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, append the agent's tool log to the review
|
|
if [ "${PI_DEBUG}" = "true" ]; then
|
|
AGENT_LOG=$(sed 's/\x1b\[[0-9;]*m//g' /tmp/pi-agent.log | head -200)
|
|
cat >> /tmp/pi-review.md << LOGEOF
|
|
|
|
---
|
|
|
|
<details>
|
|
<summary>🔍 <strong>Agent Tool Log</strong> (debug)</summary>
|
|
|
|
\`\`\`
|
|
${AGENT_LOG}
|
|
\`\`\`
|
|
|
|
</details>
|
|
LOGEOF
|
|
echo "Debug: agent 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::"
|