feat: initial pi-review Docker action
Reusable Gitea/GitHub action that runs Pi coding agent for AI-powered code reviews on pull requests. - Docker image based on node:24-slim (112 packages) - Supports built-in providers (zai, anthropic, openai, deepseek, openrouter) and custom OpenAI-compatible endpoints - Generates git diff (excludes lockfiles/generated code by default) - Posts review as idempotent PR comment (updates existing on re-run) - Read-only tools only: agent investigates but never modifies code - 80KB default diff truncation to stay within LLM context windows - No curl/python3 dependency — uses Node.js for HTTP and JSON
This commit is contained in:
40
.editorconfig
Normal file
40
.editorconfig
Normal file
@@ -0,0 +1,40 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
max_line_length = 120
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.java]
|
||||
# Mutige können die Ausnahme für .java entfernen :)
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
|
||||
[*.xml]
|
||||
# automatisches Umbrechen/Trimmen kann die Semantik vom Inhalt ändern
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Kompatibilität zu maven-dependency-plugin/maven-release-plugin... und damit zu jgitflow
|
||||
[pom.xml]
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
|
||||
[*.md]
|
||||
indent_size = 4
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{bat,ps1}]
|
||||
end_of_line = crlf
|
||||
|
||||
[api/src/test/resources/AerzteDocMergerTest/expected/*.txt]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[api/src/test/resources/DocMergerTest/expected/*.txt]
|
||||
trim_trailing_whitespace = false
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
*.log
|
||||
.vfox
|
||||
2
.vfox.toml
Normal file
2
.vfox.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
nodejs = "24.15.0"
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:24-slim
|
||||
|
||||
# node:24-slim has ~88 packages (vs 413 in bookworm).
|
||||
# We only add git for diffing. curl and python3 are replaced by Node.js.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Pi globally — baked into the image, no install at runtime
|
||||
RUN npm install -g @earendil-works/pi-coding-agent
|
||||
|
||||
# Copy action files into the image
|
||||
COPY prompts/default.md /action/prompts/default.md
|
||||
COPY scripts/review.sh /action/scripts/review.sh
|
||||
COPY entrypoint.sh /action/entrypoint.sh
|
||||
|
||||
RUN chmod +x /action/entrypoint.sh /action/scripts/review.sh
|
||||
|
||||
# Disable Pi's startup network calls
|
||||
ENV PI_OFFLINE=1
|
||||
|
||||
ENTRYPOINT ["/action/entrypoint.sh"]
|
||||
43
action.yml
Normal file
43
action.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: "Pi Code Review"
|
||||
description: "AI-powered code review using the Pi coding agent framework with any LLM provider"
|
||||
author: "homa"
|
||||
|
||||
inputs:
|
||||
api_key:
|
||||
description: "API key for the LLM provider"
|
||||
required: true
|
||||
provider:
|
||||
description: "Pi provider name (zai, anthropic, openai, openrouter, deepseek) or 'custom' for models.json config"
|
||||
required: false
|
||||
default: "zai"
|
||||
model:
|
||||
description: "Model ID (e.g. glm-4.7, claude-sonnet-4-20250514, gpt-4o)"
|
||||
required: false
|
||||
default: "glm-5.1"
|
||||
base_url:
|
||||
description: "Custom API base URL (for OpenAI-compatible providers). Ignored for built-in providers."
|
||||
required: false
|
||||
default: ""
|
||||
token:
|
||||
description: "Git platform token for posting PR comments"
|
||||
required: true
|
||||
review_prompt:
|
||||
description: "Path to a custom review prompt file (relative to calling repo root)"
|
||||
required: false
|
||||
default: ""
|
||||
exclude_patterns:
|
||||
description: "Space-separated glob patterns to exclude from the diff"
|
||||
required: false
|
||||
default: "*.lock package-lock.json yarn.lock pnpm-lock.yaml *.min.js *.min.css *.map"
|
||||
tools:
|
||||
description: "Comma-separated tools the agent can use (read-only recommended for CI)"
|
||||
required: false
|
||||
default: "read,grep,find,ls"
|
||||
max_diff_bytes:
|
||||
description: "Max diff size in bytes before truncation (0 = unlimited)"
|
||||
required: false
|
||||
default: "80000"
|
||||
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
29
entrypoint.sh
Executable file
29
entrypoint.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Entrypoint for Docker action ────────────────────────────────────────────
|
||||
# Docker actions receive inputs as INPUT_<NAME> env vars.
|
||||
# We map them to PI_* vars that review.sh expects, then run the review.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Map Docker action inputs to review.sh env vars
|
||||
export PI_API_KEY="${INPUT_API_KEY}"
|
||||
export PI_PROVIDER="${INPUT_PROVIDER:-zai}"
|
||||
export PI_MODEL="${INPUT_MODEL:-glm-5.1}"
|
||||
export PI_BASE_URL="${INPUT_BASE_URL:-}"
|
||||
export PI_TOOLS="${INPUT_TOOLS:-read,grep,find,ls}"
|
||||
export PI_REVIEW_PROMPT="${INPUT_REVIEW_PROMPT:-}"
|
||||
export PI_EXCLUDE="${INPUT_EXCLUDE_PATTERNS:-*.lock package-lock.json yarn.lock pnpm-lock.yaml *.min.js *.min.css *.map}"
|
||||
export PI_MAX_DIFF="${INPUT_MAX_DIFF_BYTES:-80000}"
|
||||
export PI_TOKEN="${INPUT_TOKEN}"
|
||||
|
||||
# The calling repo is mounted at GITHUB_WORKSPACE by both GitHub and Gitea.
|
||||
# cd into it so git commands work against the right repo.
|
||||
cd "${GITHUB_WORKSPACE:-/github/workspace}"
|
||||
|
||||
echo "Workspace: $(pwd)"
|
||||
echo "Provider: ${PI_PROVIDER}"
|
||||
echo "Model: ${PI_MODEL}"
|
||||
|
||||
# Run the review
|
||||
bash /action/scripts/review.sh
|
||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pi-review",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.1.2",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
25
prompts/default.md
Normal file
25
prompts/default.md
Normal file
@@ -0,0 +1,25 @@
|
||||
You are a senior code reviewer. Review the code changes in this pull request.
|
||||
|
||||
Process:
|
||||
1. Read the git diff at /tmp/pi-diff.txt
|
||||
2. Read any surrounding files needed for full context
|
||||
3. Analyze the changes against the criteria below
|
||||
4. Output a structured review
|
||||
|
||||
Review criteria:
|
||||
- Bugs and logic errors
|
||||
- Security vulnerabilities (injection, auth bypass, data exposure)
|
||||
- Error handling gaps (missing null checks, unhandled exceptions)
|
||||
- Race conditions or concurrency issues
|
||||
- Breaking changes to public APIs
|
||||
- Maintainability Issues and Clean Code (DRY, Complexity, Leasts Surprise)
|
||||
|
||||
Output format:
|
||||
- Start with a one-line summary of what this PR does
|
||||
- List findings grouped by severity:
|
||||
- 🔴 **Critical**: Must fix before merge (bugs, security)
|
||||
- 🟡 **Warning**: Should fix (logic gaps, missing error handling)
|
||||
- 🟢 **Suggestion**: Nice to have (readability, minor improvements)
|
||||
- End with a verdict: **Approve** or **Request Changes**
|
||||
- Skip style-only and formatting comments
|
||||
- If the PR looks good with no issues, say so and approve
|
||||
242
scripts/review.sh
Executable file
242
scripts/review.sh
Executable file
@@ -0,0 +1,242 @@
|
||||
#!/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)
|
||||
pi --no-session \
|
||||
${PROVIDER_FLAG} \
|
||||
--model "${PI_MODEL}" \
|
||||
--tools "${PI_TOOLS}" \
|
||||
-p "${PROMPT}" \
|
||||
> /tmp/pi-review.md 2>/dev/null
|
||||
|
||||
if [ ! -s /tmp/pi-review.md ]; then
|
||||
echo "::error::Pi generated no output"
|
||||
exit 1
|
||||
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::"
|
||||
Reference in New Issue
Block a user