#!/usr/bin/env bash # shellcheck disable=SC2015,SC2016 # review-pr.sh — Thin orchestrator for AI PR review (formula: formulas/review-pr.toml) # Usage: ./review-pr.sh [--force] set -euo pipefail source "$(dirname "$0")/../lib/env.sh" source "$(dirname "$0")/../lib/ci-helpers.sh" source "$(dirname "$0")/../lib/agent-session.sh" git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true PR_NUMBER="${1:?Usage: review-pr.sh [--force]}" FORCE="${2:-}" API="${FORGE_API}" LOGFILE="${FACTORY_ROOT}/review/review.log" SESSION="review-${PROJECT_NAME}-${PR_NUMBER}" PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase" OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json" WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}" LOCKFILE="/tmp/${PROJECT_NAME}-review.lock" STATUSFILE="/tmp/${PROJECT_NAME}-review-status" MAX_DIFF=25000 REVIEW_TMPDIR=$(mktemp -d) 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 "$REVIEW_TMPDIR" "$LOCKFILE" "$STATUSFILE"; } trap cleanup EXIT if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then mv "$LOGFILE" "$LOGFILE.old" fi AVAIL=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo) [ "$AVAIL" -lt 1500 ] && { log "SKIP: ${AVAIL}MB available"; exit 0; } if [ -f "$LOCKFILE" ]; then LPID=$(cat "$LOCKFILE" 2>/dev/null || true) [ -n "$LPID" ] && kill -0 "$LPID" 2>/dev/null && { log "SKIP: locked"; exit 0; } rm -f "$LOCKFILE" fi echo $$ > "$LOCKFILE" status "fetching metadata" PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}") PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title') PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""') PR_HEAD=$(printf '%s' "$PR_JSON" | jq -r '.head.ref') PR_BASE=$(printf '%s' "$PR_JSON" | jq -r '.base.ref') PR_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha') PR_STATE=$(printf '%s' "$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}"; agent_kill_session "$SESSION" cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0 fi CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') CI_NOTE=""; if ! ci_passed "$CI_STATE"; then ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; } CI_NOTE=" (not required — non-code PR)"; fi ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments") HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \ '[.[]|select(.body|contains(""))]|length') [ "${HAS_CMT:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: reviewed ${PR_SHA:0:7}"; exit 0; } HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \ '[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length') [ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; } PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA="" PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \ '[.[]|select(.body|contains("\nReview failed.\n---\n*${PR_SHA:0:7}*" \ '{body: $b}' | curl -sf -o /dev/null -X POST -H "Authorization: token ${FORGE_TOKEN}" \ -H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true matrix_send "review" "PR #${PR_NUMBER} review failed" 2>/dev/null || true; exit 1 fi VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_') REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""') REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""') log "verdict: ${VERDICT}" status "posting review" RTYPE="Review" if [ "$IS_RE_REVIEW" = true ]; then RTYPE="Re-review (round $(($(printf '%s' "$ALL_COMMENTS" | \ jq '[.[]|select(.body|contains("\n\n%s\n\n### Verdict\n**%s** — %s\n\n---\n*Reviewed at `%s`%s | [AGENTS.md](AGENTS.md)*' \ "$RTYPE" "$PR_SHA" "$REVIEW_MD" "$VERDICT" "$REASON" "${PR_SHA:0:7}" "$PREV_REF") printf '%s' "$COMMENT_BODY" > "${REVIEW_TMPDIR}/body.txt" jq -Rs '{body: .}' < "${REVIEW_TMPDIR}/body.txt" > "${REVIEW_TMPDIR}/comment.json" POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ -H "Authorization: token ${FORGE_REVIEW_TOKEN}" -H "Content-Type: application/json" \ "${API}/issues/${PR_NUMBER}/comments" --data-binary @"${REVIEW_TMPDIR}/comment.json") [ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; } log "posted review comment" REVENT="COMMENT" case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac if [ "$REVENT" = "APPROVED" ]; then BLOGIN=$(curl -sf -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \ "${API%%/repos*}/user" 2>/dev/null | jq -r '.login // empty' || true) [ -n "$BLOGIN" ] && forge_api_all "/pulls/${PR_NUMBER}/reviews" "${FORGE_REVIEW_TOKEN}" 2>/dev/null | \ jq -r --arg l "$BLOGIN" '.[]|select(.state=="REQUEST_CHANGES")|select(.user.login==$l)|.id' | \ while IFS= read -r rid; do curl -sf -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \ -H "Content-Type: application/json" "${API}/pulls/${PR_NUMBER}/reviews/${rid}/dismissals" \ -d '{"message":"Superseded by approval"}' || true; log "dismissed review ${rid}" done || true fi jq -n --arg b "AI ${RTYPE}: **${VERDICT}** — ${REASON}" --arg e "$REVENT" --arg s "$PR_SHA" \ '{body: $b, event: $e, commit_id: $s}' > "${REVIEW_TMPDIR}/formal.json" curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \ -H "Content-Type: application/json" "${API}/pulls/${PR_NUMBER}/reviews" \ --data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true log "formal ${REVENT} submitted" matrix_send "review" "PR #${PR_NUMBER} ${RTYPE}: ${VERDICT} — ${PR_TITLE}" "" "$PR_NUMBER" >/dev/null 2>&1 || true case "$VERDICT" in REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;; *) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}" git worktree remove "$WORKTREE" --force 2>/dev/null || true rm -rf "$WORKTREE" 2>/dev/null || true ;; esac log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"