#!/usr/bin/env bash # review-pr.sh — AI-powered PR review using claude CLI # # Usage: ./scripts/review-pr.sh [--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 [--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(" ${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)"