5cd267296e
Refine parsing of assistant tool calls in review.sh to handle "toolCall" and "arguments" structures, aligning with Pi session format changes.
411 lines
13 KiB
Bash
Executable File
411 lines
13 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.
|
|
|
|
# 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:-<none found>}"
|
|
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
|
|
|
|
---
|
|
|
|
<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"
|
|
|
|
# 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="<!-- 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::"
|