actions/checkout for PRs only fetches refs/pull/N/head into FETCH_HEAD. Remote tracking branches (origin/main) don't exist. Fix: explicit refspec fetch (refs/heads/main:refs/remotes/origin/main) with fallback to GITEA_BASE_REF / GITHUB_BASE_REF for the target branch. Also removed --filter=blob:none which could cause empty diffs. Added diagnostic logging for base ref and file stats.
325 lines
10 KiB
Bash
Executable File
325 lines
10 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"
|
|
|
|
# actions/checkout for PRs only fetches the PR ref (refs/pull/N/head).
|
|
# It does NOT create remote tracking branches like origin/main.
|
|
# We must explicitly fetch the base branch.
|
|
|
|
# Unshallow if needed (fetch-depth: 0 already does this, but be safe)
|
|
git fetch --unshallow origin 2>/dev/null || true
|
|
|
|
# Fetch base branch with explicit refspec to ensure origin/main exists
|
|
if git fetch origin refs/heads/main:refs/remotes/origin/main 2>/dev/null; then
|
|
BASE="origin/main"
|
|
elif git fetch origin refs/heads/master:refs/remotes/origin/master 2>/dev/null; then
|
|
BASE="origin/master"
|
|
else
|
|
# Fallback: try Gitea/GitHub event context for the target branch
|
|
TARGET_BRANCH="${GITEA_BASE_REF:-${GITHUB_BASE_REF:-}}"
|
|
if [ -n "${TARGET_BRANCH}" ] && git fetch origin "refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}" 2>/dev/null; then
|
|
BASE="origin/${TARGET_BRANCH}"
|
|
else
|
|
echo "::warning::Could not fetch base branch. Trying origin/HEAD."
|
|
git fetch origin 2>/dev/null || true
|
|
BASE="origin/HEAD"
|
|
fi
|
|
fi
|
|
|
|
echo "Base ref: ${BASE} -> $(git rev-parse --short "${BASE}" 2>/dev/null || echo 'NOT FOUND')"
|
|
echo "HEAD: $(git rev-parse --short HEAD)"
|
|
echo "Files changed:"
|
|
git diff --stat "${BASE}...HEAD" 2>/dev/null | tail -3 || echo "(could not stat diff)"
|
|
|
|
# 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::"
|