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.
402 lines
12 KiB
Bash
Executable File
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::"
|