270 lines
7.1 KiB
Bash
Executable file
270 lines
7.1 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# review-pr.sh — AI-powered PR review using claude CLI
|
|
#
|
|
# Usage: ./scripts/review-pr.sh <pr-number> [--force]
|
|
#
|
|
# Calls `claude -p --model sonnet` with context docs + diff.
|
|
# No tool access (pure text review), ~$0.02-0.05 per review.
|
|
#
|
|
# --force: skip the "already reviewed" check
|
|
#
|
|
# Concurrency: uses a lockfile to ensure only one review runs at a time.
|
|
# Status: writes live progress to /tmp/harb-review-status for peeking.
|
|
# Logs: /home/debian/harb/logs/review.log (auto-rotated at 100KB)
|
|
#
|
|
# Peek while running: cat /tmp/harb-review-status
|
|
# Watch log: tail -f ~/harb/logs/review.log
|
|
|
|
set -euo pipefail
|
|
|
|
# --- Environment (cron-safe) ---
|
|
export PATH="/home/debian/.nvm/versions/node/v22.20.0/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
|
export HOME="${HOME:-/home/debian}"
|
|
|
|
# --- Config ---
|
|
PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}"
|
|
FORCE="${2:-}"
|
|
REPO="johba/harb"
|
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
CODEBERG_TOKEN="$(awk '/codeberg.org/{getline;getline;print $2}' ~/.netrc)"
|
|
API_BASE="https://codeberg.org/api/v1/repos/${REPO}"
|
|
LOCKFILE="/tmp/harb-review.lock"
|
|
STATUSFILE="/tmp/harb-review-status"
|
|
LOGDIR="/home/debian/harb/logs"
|
|
LOGFILE="$LOGDIR/review.log"
|
|
MIN_MEM_MB=1500
|
|
TMPDIR=$(mktemp -d)
|
|
|
|
mkdir -p "$LOGDIR"
|
|
|
|
# --- Logging ---
|
|
log() {
|
|
local ts
|
|
ts="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
|
echo "[$ts] PR#${PR_NUMBER} $*" | tee -a "$LOGFILE"
|
|
}
|
|
|
|
status() {
|
|
local ts
|
|
ts="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
|
printf '[%s] PR #%s: %s\n' "$ts" "$PR_NUMBER" "$*" > "$STATUSFILE"
|
|
log "$*"
|
|
}
|
|
|
|
cleanup() {
|
|
rm -rf "$TMPDIR"
|
|
rm -f "$LOCKFILE" "$STATUSFILE"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- Log rotation (keep ~100KB + 1 archive) ---
|
|
if [ -f "$LOGFILE" ]; then
|
|
LOGSIZE=$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)
|
|
if [ "$LOGSIZE" -gt 102400 ]; then
|
|
mv "$LOGFILE" "$LOGFILE.old"
|
|
log "Log rotated (was ${LOGSIZE} bytes)"
|
|
fi
|
|
fi
|
|
|
|
# --- Memory guard ---
|
|
AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
|
if [ "$AVAIL_MB" -lt "$MIN_MEM_MB" ]; then
|
|
log "SKIP: only ${AVAIL_MB}MB available (need ${MIN_MEM_MB}MB)"
|
|
exit 0
|
|
fi
|
|
|
|
# --- Concurrency lock ---
|
|
if [ -f "$LOCKFILE" ]; then
|
|
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
|
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
log "SKIP: another review running (PID ${LOCK_PID})"
|
|
exit 0
|
|
fi
|
|
log "Removing stale lock (PID ${LOCK_PID:-?})"
|
|
rm -f "$LOCKFILE"
|
|
fi
|
|
echo $$ > "$LOCKFILE"
|
|
|
|
# --- Fetch PR metadata ---
|
|
status "fetching metadata"
|
|
PR_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API_BASE}/pulls/${PR_NUMBER}")
|
|
|
|
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
|
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
|
PR_HEAD=$(echo "$PR_JSON" | jq -r '.head.ref')
|
|
PR_BASE=$(echo "$PR_JSON" | jq -r '.base.ref')
|
|
PR_SHA=$(echo "$PR_JSON" | jq -r '.head.sha')
|
|
PR_STATE=$(echo "$PR_JSON" | jq -r '.state')
|
|
|
|
log "${PR_TITLE} (${PR_HEAD}→${PR_BASE} ${PR_SHA:0:7})"
|
|
|
|
# --- Guards ---
|
|
if [ "$PR_STATE" != "open" ]; then
|
|
log "SKIP: state=${PR_STATE}"
|
|
exit 0
|
|
fi
|
|
|
|
status "checking CI"
|
|
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API_BASE}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"')
|
|
|
|
if [ "$CI_STATE" != "success" ]; then
|
|
log "SKIP: CI=${CI_STATE}"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$FORCE" != "--force" ]; then
|
|
status "checking existing reviews"
|
|
EXISTING=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API_BASE}/issues/${PR_NUMBER}/comments?limit=50" | \
|
|
jq -r --arg sha "$PR_SHA" \
|
|
'[.[] | select(.body | contains("<!-- reviewed:")) | select(.body | contains($sha))] | length')
|
|
|
|
if [ "$EXISTING" -gt "0" ]; then
|
|
log "SKIP: already reviewed at ${PR_SHA:0:7}"
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# --- Fetch diff ---
|
|
status "fetching diff"
|
|
DIFF=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API_BASE}/pulls/${PR_NUMBER}.diff" | head -c 25000)
|
|
|
|
DIFF_STAT=$(echo "$DIFF" | grep -E '^\+\+\+ b/' | sed 's|^+++ b/||' | sort)
|
|
|
|
# --- Which context docs? ---
|
|
NEEDS_UX=false
|
|
for f in $DIFF_STAT; do
|
|
case "$f" in
|
|
landing/*|web-app/*) NEEDS_UX=true ;;
|
|
esac
|
|
done
|
|
|
|
# --- Build prompt file ---
|
|
status "building prompt"
|
|
cat > "${TMPDIR}/prompt.md" << PROMPT_EOF
|
|
# PR #${PR_NUMBER}: ${PR_TITLE}
|
|
|
|
## PR Description
|
|
${PR_BODY}
|
|
|
|
## Changed Files
|
|
${DIFF_STAT}
|
|
|
|
## PRODUCT-TRUTH.md (what we can/cannot claim)
|
|
$(cat "${REPO_ROOT}/docs/PRODUCT-TRUTH.md")
|
|
|
|
## ARCHITECTURE.md
|
|
$(cat "${REPO_ROOT}/docs/ARCHITECTURE.md")
|
|
PROMPT_EOF
|
|
|
|
if [ "$NEEDS_UX" = true ] && [ -f "${REPO_ROOT}/docs/UX-DECISIONS.md" ]; then
|
|
cat >> "${TMPDIR}/prompt.md" << UX_EOF
|
|
|
|
## UX-DECISIONS.md
|
|
$(cat "${REPO_ROOT}/docs/UX-DECISIONS.md")
|
|
UX_EOF
|
|
fi
|
|
|
|
cat >> "${TMPDIR}/prompt.md" << DIFF_EOF
|
|
|
|
## Diff
|
|
\`\`\`diff
|
|
${DIFF}
|
|
\`\`\`
|
|
|
|
## Your Task
|
|
Produce a structured review:
|
|
|
|
### 1. Claim Check
|
|
Extract every factual claim about the protocol from user-facing text in the diff.
|
|
For each, verify against PRODUCT-TRUTH.md:
|
|
- ✅ Accurate
|
|
- ⚠️ Partially true (explain)
|
|
- ❌ False (cite contradiction)
|
|
If no claims, say "No user-facing claims in this diff."
|
|
|
|
### 2. Code Review
|
|
Bugs, logic errors, missing edge cases, broken imports.
|
|
|
|
### 3. Architecture Check
|
|
Does this follow patterns in ARCHITECTURE.md?
|
|
|
|
### 4. UX/Messaging Check
|
|
Does copy follow UX-DECISIONS.md?
|
|
(Skip if no UX-DECISIONS context provided.)
|
|
|
|
### 5. Verdict
|
|
**APPROVE**, **REQUEST_CHANGES**, or **DISCUSS** — one line reason.
|
|
|
|
Be direct. No filler.
|
|
DIFF_EOF
|
|
|
|
PROMPT_SIZE=$(stat -c%s "${TMPDIR}/prompt.md")
|
|
log "Prompt: ${PROMPT_SIZE} bytes"
|
|
|
|
# --- Run claude -p ---
|
|
status "running claude (sonnet)"
|
|
SECONDS=0
|
|
|
|
REVIEW=$(claude -p \
|
|
--model sonnet \
|
|
--dangerously-skip-permissions \
|
|
--output-format text \
|
|
< "${TMPDIR}/prompt.md" 2>"${TMPDIR}/claude-stderr.log")
|
|
|
|
ELAPSED=$SECONDS
|
|
CLAUDE_EXIT=$?
|
|
|
|
if [ $CLAUDE_EXIT -ne 0 ]; then
|
|
log "ERROR: claude exited ${CLAUDE_EXIT} after ${ELAPSED}s"
|
|
log "stderr: $(cat "${TMPDIR}/claude-stderr.log" | tail -5)"
|
|
exit 1
|
|
fi
|
|
|
|
if [ -z "$REVIEW" ]; then
|
|
log "ERROR: empty review after ${ELAPSED}s"
|
|
exit 1
|
|
fi
|
|
|
|
REVIEW_SIZE=$(echo "$REVIEW" | wc -c)
|
|
log "Review: ${REVIEW_SIZE} bytes in ${ELAPSED}s"
|
|
|
|
# --- Post to Codeberg ---
|
|
status "posting to Codeberg"
|
|
|
|
COMMENT_BODY="## 🤖 AI Review
|
|
<!-- reviewed: ${PR_SHA} -->
|
|
|
|
${REVIEW}
|
|
|
|
---
|
|
*Reviewed at \`${PR_SHA:0:7}\` · [PRODUCT-TRUTH.md](../docs/PRODUCT-TRUTH.md) · [ARCHITECTURE.md](../docs/ARCHITECTURE.md)*"
|
|
|
|
POST_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
|
-X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API_BASE}/issues/${PR_NUMBER}/comments" \
|
|
-d "$(jq -n --arg body "$COMMENT_BODY" '{body: $body}')")
|
|
|
|
if [ "${POST_CODE}" = "201" ]; then
|
|
log "POSTED to Codeberg"
|
|
else
|
|
log "ERROR: Codeberg HTTP ${POST_CODE}"
|
|
echo "$REVIEW" > "${LOGDIR}/review-pr${PR_NUMBER}-${PR_SHA:0:7}.md"
|
|
log "Review saved to ${LOGDIR}/review-pr${PR_NUMBER}-${PR_SHA:0:7}.md"
|
|
exit 1
|
|
fi
|
|
|
|
# --- Notify OpenClaw (best effort) ---
|
|
VERDICT=$(echo "$REVIEW" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*')
|
|
if command -v openclaw &>/dev/null; then
|
|
openclaw system event \
|
|
--text "🤖 PR #${PR_NUMBER} reviewed: ${VERDICT:-UNKNOWN} — ${PR_TITLE}" \
|
|
--mode now 2>/dev/null || true
|
|
fi
|
|
|
|
log "DONE: ${VERDICT:-UNKNOWN} (${ELAPSED}s)"
|