#!/usr/bin/env bash # review-pr.sh — AI-powered PR review using persistent Claude tmux session # # Usage: ./review-pr.sh [--force] # # Session lifecycle: # 1. Creates/reuses tmux session: review-{project}-{pr} # 2. Injects PR diff + review guidelines into interactive claude # 3. Claude reviews, writes structured JSON to output file # 4. Script posts review to Codeberg # 5. Session stays alive for re-reviews and human questions # # Re-review (new commits pushed): # Same session → Claude remembers previous findings, verifies they're addressed # # Review output: /tmp/{project}-review-output-{pr}.json # Phase file: /tmp/review-session-{project}-{pr}.phase # Session: review-{project}-{pr} (tmux) # Peek: cat /tmp/-review-status # Log: tail -f /review/review.log set -euo pipefail # Load shared environment source "$(dirname "$0")/../lib/env.sh" # Auto-pull factory code to pick up merged fixes before any logic runs git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true PR_NUMBER="${1:?Usage: review-pr.sh [--force]}" FORCE="${2:-}" # shellcheck disable=SC2034 REPO="${CODEBERG_REPO}" # shellcheck disable=SC2034 REPO_ROOT="${PROJECT_REPO_ROOT}" # Bot account for posting reviews (separate user required for branch protection approvals) API_BASE="${CODEBERG_API}" LOCKFILE="/tmp/${PROJECT_NAME}-review.lock" STATUSFILE="/tmp/${PROJECT_NAME}-review-status" LOGDIR="${FACTORY_ROOT}/review" LOGFILE="$LOGDIR/review.log" MIN_MEM_MB=1500 MAX_DIFF=25000 MAX_ATTEMPTS=2 TMPDIR=$(mktemp -d) # Tmux session + review output protocol SESSION_NAME="review-${PROJECT_NAME}-${PR_NUMBER}" PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase" REVIEW_OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json" REVIEW_THREAD_MAP="/tmp/review-thread-map" REVIEW_WAIT_INTERVAL=10 # seconds between phase checks REVIEW_WAIT_TIMEOUT=600 # 10 min max for a single review cycle log() { printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE" } status() { printf '[%s] PR #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" > "$STATUSFILE" log "$*" } cleanup() { rm -rf "$TMPDIR" rm -f "$LOCKFILE" "$STATUSFILE" # tmux session persists for re-reviews and human questions } trap cleanup EXIT # Log rotation (100KB + 1 archive) if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then mv "$LOGFILE" "$LOGFILE.old" log "Log rotated" 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" # --- Tmux session helpers --- wait_for_claude_ready() { local timeout="${1:-120}" local elapsed=0 while [ "$elapsed" -lt "$timeout" ]; do # Check for Claude prompt: ❯ (UTF-8) or fallback to $ at line start local pane_out pane_out=$(tmux capture-pane -t "${SESSION_NAME}" -p 2>/dev/null || true) if printf '%s' "$pane_out" | grep -qE '❯|^\$' 2>/dev/null; then return 0 fi sleep 2 elapsed=$((elapsed + 2)) done log "WARNING: claude not ready after ${timeout}s — proceeding anyway" return 1 } inject_into_session() { local text="$1" local tmpfile wait_for_claude_ready 120 || true tmpfile=$(mktemp /tmp/review-inject-XXXXXX) printf '%s' "$text" > "$tmpfile" # All tmux calls guarded with || true: the session is external and may die # between the has-session check and here; a non-zero exit must not abort # the script under set -euo pipefail. tmux load-buffer -b "review-inject-${PR_NUMBER}" "$tmpfile" || true tmux paste-buffer -t "${SESSION_NAME}" -b "review-inject-${PR_NUMBER}" || true sleep 0.5 tmux send-keys -t "${SESSION_NAME}" "" Enter || true tmux delete-buffer -b "review-inject-${PR_NUMBER}" 2>/dev/null || true rm -f "$tmpfile" } wait_for_review_output() { local timeout="$REVIEW_WAIT_TIMEOUT" local elapsed=0 while [ "$elapsed" -lt "$timeout" ]; do # Check phase file before sleeping (avoids mandatory delay on fast reviews) if ! tmux has-session -t "${SESSION_NAME}" 2>/dev/null; then log "ERROR: session died during review" return 1 fi local phase phase=$(head -1 "$PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true) if [ "$phase" = "PHASE:review_complete" ]; then return 0 fi sleep "$REVIEW_WAIT_INTERVAL" elapsed=$((elapsed + REVIEW_WAIT_INTERVAL)) done log "ERROR: review did not complete within ${timeout}s" return 1 } # --- 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})" if [ "$PR_STATE" != "open" ]; then log "SKIP: state=${PR_STATE}" cd "$REPO_ROOT" # Kill review session for non-open PR tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true git worktree remove "/tmp/${PROJECT_NAME}-review-${PR_NUMBER}" --force 2>/dev/null || true rm -rf "/tmp/${PROJECT_NAME}-review-${PR_NUMBER}" 2>/dev/null || true rm -f "${PHASE_FILE}" "${REVIEW_OUTPUT_FILE}" 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 # Projects without CI (woodpecker_repo_id=0) treat empty/pending as pass if [ "${WOODPECKER_REPO_ID:-2}" = "0" ] && { [ -z "$CI_STATE" ] || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ]; }; then log "no CI configured, proceeding without CI gate" else log "SKIP: CI=${CI_STATE}" exit 0 fi fi # --- Check for existing reviews --- status "checking existing reviews" ALL_COMMENTS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API_BASE}/issues/${PR_NUMBER}/comments?limit=50") # Check review-comment watermarks — skip if a comment with exists COMMENT_REVIEWED=$(echo "$ALL_COMMENTS" | \ jq -r --arg sha "$PR_SHA" \ '[.[] | select(.body | contains(""))] | length') if [ "${COMMENT_REVIEWED:-0}" -gt "0" ] && [ "$FORCE" != "--force" ]; then log "SKIP: review comment exists for ${PR_SHA:0:7}" exit 0 fi # Check formal Codeberg reviews — skip if a non-stale review exists for this SHA EXISTING=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API_BASE}/pulls/${PR_NUMBER}/reviews" | \ jq -r --arg sha "$PR_SHA" \ '[.[] | select(.commit_id == $sha) | select(.state != "COMMENT")] | length') if [ "${EXISTING:-0}" -gt "0" ] && [ "$FORCE" != "--force" ]; then log "SKIP: formal review exists for ${PR_SHA:0:7}" exit 0 fi # Find previous review for re-review mode PREV_REVIEW_JSON=$(echo "$ALL_COMMENTS" | \ jq -r --arg sha "$PR_SHA" \ '[.[] | select(.body | contains(" Review failed: could not produce structured output after ${MAX_ATTEMPTS} attempts. A maintainer should review this PR manually, or re-trigger with \`--force\`. --- *Failed at \`${PR_SHA:0:7}\`*" printf '%s' "$ERROR_BODY" > "${TMPDIR}/comment-body.txt" jq -Rs '{body: .}' < "${TMPDIR}/comment-body.txt" > "${TMPDIR}/comment.json" curl -s -o /dev/null -w "%{http_code}" \ -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API_BASE}/issues/${PR_NUMBER}/comments" \ --data-binary @"${TMPDIR}/comment.json" > /dev/null # Save raw outputs for debugging for f in "${LOGDIR}"/review-pr"${PR_NUMBER}"-raw-attempt-*.txt; do [ -f "$f" ] && log "raw output saved: $f" done matrix_send "review" "PR #${PR_NUMBER} review failed — no valid JSON output" 2>/dev/null || true exit 1 fi # --- Render JSON -> Markdown --- VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict') VERDICT_REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""') render_markdown() { local json="$1" local md="" if [ "$IS_RE_REVIEW" = true ]; then # Re-review format local prev_count prev_count=$(printf '%s' "$json" | jq '.previous_findings | length') if [ "$prev_count" -gt 0 ]; then md+="### Previous Findings"$'\n' while IFS= read -r finding; do local summary finding_status explanation summary=$(printf '%s' "$finding" | jq -r '.summary') finding_status=$(printf '%s' "$finding" | jq -r '.status') explanation=$(printf '%s' "$finding" | jq -r '.explanation') local icon="?" case "$finding_status" in fixed) icon="FIXED" ;; not_fixed) icon="NOT FIXED" ;; partial) icon="PARTIAL" ;; esac md+="- ${summary} -> ${icon} ${explanation}"$'\n' done < <(printf '%s' "$json" | jq -c '.previous_findings[]') md+=$'\n' fi local new_count new_count=$(printf '%s' "$json" | jq '.new_issues | length') if [ "$new_count" -gt 0 ]; then md+="### New Issues"$'\n' while IFS= read -r issue; do local sev loc desc sev=$(printf '%s' "$issue" | jq -r '.severity') loc=$(printf '%s' "$issue" | jq -r '.location') desc=$(printf '%s' "$issue" | jq -r '.description') md+="- **${sev}** \`${loc}\`: ${desc}"$'\n' done < <(printf '%s' "$json" | jq -c '.new_issues[]') md+=$'\n' fi else # Fresh review format while IFS= read -r section; do local title title=$(printf '%s' "$section" | jq -r '.title') local finding_count finding_count=$(printf '%s' "$section" | jq '.findings | length') md+="### ${title}"$'\n' if [ "$finding_count" -eq 0 ]; then md+="No issues found."$'\n'$'\n' else while IFS= read -r finding; do local sev loc desc sev=$(printf '%s' "$finding" | jq -r '.severity') loc=$(printf '%s' "$finding" | jq -r '.location') desc=$(printf '%s' "$finding" | jq -r '.description') md+="- **${sev}** \`${loc}\`: ${desc}"$'\n' done < <(printf '%s' "$section" | jq -c '.findings[]') md+=$'\n' fi done < <(printf '%s' "$json" | jq -c '.sections[]') fi # Follow-ups local followup_count followup_count=$(printf '%s' "$json" | jq '.followups | length') if [ "$followup_count" -gt 0 ]; then md+="### Follow-up Issues"$'\n' while IFS= read -r fu; do local fu_title fu_details fu_title=$(printf '%s' "$fu" | jq -r '.title') fu_details=$(printf '%s' "$fu" | jq -r '.details') md+="- **${fu_title}**: ${fu_details}"$'\n' done < <(printf '%s' "$json" | jq -c '.followups[]') md+=$'\n' fi # Verdict md+="### Verdict"$'\n' md+="**${VERDICT}** — ${VERDICT_REASON}"$'\n' printf '%s' "$md" } REVIEW_MD=$(render_markdown "$REVIEW_JSON") # --- Post review to Codeberg --- status "posting to Codeberg" REVIEW_TYPE="Review" if [ "$IS_RE_REVIEW" = true ]; then ROUND=$(($(echo "$ALL_COMMENTS" | jq '[.[] | select(.body | contains(" ${REVIEW_MD} --- *Reviewed at \`${PR_SHA:0:7}\`$(if [ "$IS_RE_REVIEW" = true ]; then echo " | Previous: \`${PREV_REVIEW_SHA:0:7}\`"; fi) | [AGENTS.md](AGENTS.md)*" printf '%s' "$COMMENT_BODY" > "${TMPDIR}/comment-body.txt" jq -Rs '{body: .}' < "${TMPDIR}/comment-body.txt" > "${TMPDIR}/comment.json" POST_CODE=$(curl -s -o "${TMPDIR}/post-response.txt" -w "%{http_code}" \ -X POST \ -H "Authorization: token ${REVIEW_BOT_TOKEN}" \ -H "Content-Type: application/json" \ "${API_BASE}/issues/${PR_NUMBER}/comments" \ --data-binary @"${TMPDIR}/comment.json") if [ "${POST_CODE}" = "201" ]; then log "POSTED comment to Codeberg (as review_bot)" # Submit formal Codeberg review (required for branch protection approval) REVIEW_EVENT="COMMENT" case "$VERDICT" in APPROVE) REVIEW_EVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVIEW_EVENT="REQUEST_CHANGES" ;; esac FORMAL_BODY="AI ${REVIEW_TYPE}: **${VERDICT}** — ${VERDICT_REASON}" jq -n --arg body "$FORMAL_BODY" --arg event "$REVIEW_EVENT" --arg sha "$PR_SHA" \ '{body: $body, event: $event, commit_id: $sha}' > "${TMPDIR}/formal-review.json" REVIEW_CODE=$(curl -s -o "${TMPDIR}/review-response.txt" -w "%{http_code}" \ -X POST \ -H "Authorization: token ${REVIEW_BOT_TOKEN}" \ -H "Content-Type: application/json" \ "${API_BASE}/pulls/${PR_NUMBER}/reviews" \ --data-binary @"${TMPDIR}/formal-review.json") if [ "${REVIEW_CODE}" = "200" ]; then log "SUBMITTED formal ${REVIEW_EVENT} review" else log "WARNING: formal review failed (HTTP ${REVIEW_CODE}): $(head -c 200 "${TMPDIR}/review-response.txt" 2>/dev/null)" # Non-fatal — the comment is already posted fi else log "ERROR: Codeberg HTTP ${POST_CODE}: $(head -c 200 "${TMPDIR}/post-response.txt" 2>/dev/null)" echo "$REVIEW_MD" > "${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 # --- Auto-create follow-up issues from JSON --- FOLLOWUP_COUNT=$(printf '%s' "$REVIEW_JSON" | jq '.followups | length') if [ "$FOLLOWUP_COUNT" -gt 0 ]; then log "processing ${FOLLOWUP_COUNT} follow-up issues" TECH_DEBT_ID=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API_BASE}/labels" | jq -r '.[] | select(.name=="tech-debt") | .id') if [ -z "$TECH_DEBT_ID" ]; then TECH_DEBT_ID=$(curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API_BASE}/labels" \ -d '{"name":"tech-debt","color":"#6B7280","description":"Pre-existing tech debt flagged by AI review"}' | jq -r '.id') fi CREATED_COUNT=0 while IFS= read -r fu; do FU_TITLE=$(printf '%s' "$fu" | jq -r '.title') FU_DETAILS=$(printf '%s' "$fu" | jq -r '.details') # Check for duplicate EXISTING=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API_BASE}/issues?state=open&labels=tech-debt&limit=50" | \ jq -r --arg t "$FU_TITLE" '[.[] | select(.title == $t)] | length') if [ "${EXISTING:-0}" -gt 0 ]; then log "skip duplicate follow-up: ${FU_TITLE}" continue fi ISSUE_BODY="Flagged by AI reviewer in PR #${PR_NUMBER}. ## Problem ${FU_DETAILS} --- *Auto-created from AI review of PR #${PR_NUMBER}*" printf '%s' "$ISSUE_BODY" > "${TMPDIR}/followup-body.txt" jq -n \ --arg title "$FU_TITLE" \ --rawfile body "${TMPDIR}/followup-body.txt" \ --argjson labels "[$TECH_DEBT_ID]" \ '{title: $title, body: $body, labels: $labels}' > "${TMPDIR}/followup-issue.json" CREATED=$(curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API_BASE}/issues" \ --data-binary @"${TMPDIR}/followup-issue.json" | jq -r '.number // empty') if [ -n "$CREATED" ]; then log "created follow-up issue #${CREATED}: ${FU_TITLE}" CREATED_COUNT=$((CREATED_COUNT + 1)) fi done < <(printf '%s' "$REVIEW_JSON" | jq -c '.followups[]') log "created ${CREATED_COUNT} follow-up issues total" fi # --- Notify Matrix (with thread mapping for human questions) --- EVENT_ID=$(matrix_send "review" "PR #${PR_NUMBER} ${REVIEW_TYPE}: ${VERDICT} — ${PR_TITLE}" 2>/dev/null || true) if [ -n "$EVENT_ID" ]; then printf '%s\t%s\n' "$EVENT_ID" "$PR_NUMBER" >> "$REVIEW_THREAD_MAP" 2>/dev/null || true fi log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"