fix: feat: persistent Claude tmux session for reviewer (#78)
Rewrite review-pr.sh to use persistent tmux sessions instead of one-shot
claude -p. Re-reviews inject incremental diffs into the same session so
Claude remembers what it flagged and can verify fixes were addressed.
- review-pr.sh: tmux session review-{project}-{pr}, phase protocol
(PHASE:review_complete), JSON output via file, retry on invalid JSON
- review-poll.sh: session lifecycle cleanup (merged/closed PRs, 4h idle)
- matrix_listener.sh: route human questions to review sessions via
/tmp/review-thread-map
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5ec587a7b6
commit
85d05cdee2
3 changed files with 263 additions and 100 deletions
|
|
@ -145,6 +145,36 @@ while true; do
|
||||||
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply
|
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply
|
||||||
matrix_send "dev" "✓ received, will inject on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
matrix_send "dev" "✓ received, will inject on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||||
;;
|
;;
|
||||||
|
review)
|
||||||
|
# Route human questions to persistent review tmux session
|
||||||
|
REVIEW_PR_NUM=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $2}' /tmp/review-thread-map 2>/dev/null || true)
|
||||||
|
if [ -n "$REVIEW_PR_NUM" ]; then
|
||||||
|
REVIEW_SESSION="review-${PROJECT_NAME}-${REVIEW_PR_NUM}"
|
||||||
|
if tmux has-session -t "$REVIEW_SESSION" 2>/dev/null; then
|
||||||
|
REVIEW_INJECT_MSG="Human question from ${SENDER} in Matrix:
|
||||||
|
|
||||||
|
${BODY}
|
||||||
|
|
||||||
|
Please answer this question about your review. Explain your reasoning."
|
||||||
|
REVIEW_INJECT_TMP=$(mktemp /tmp/review-q-inject-XXXXXX)
|
||||||
|
printf '%s' "$REVIEW_INJECT_MSG" > "$REVIEW_INJECT_TMP"
|
||||||
|
tmux load-buffer -b "review-q-${REVIEW_PR_NUM}" "$REVIEW_INJECT_TMP" || true
|
||||||
|
tmux paste-buffer -t "$REVIEW_SESSION" -b "review-q-${REVIEW_PR_NUM}" || true
|
||||||
|
sleep 0.5
|
||||||
|
tmux send-keys -t "$REVIEW_SESSION" "" Enter || true
|
||||||
|
tmux delete-buffer -b "review-q-${REVIEW_PR_NUM}" 2>/dev/null || true
|
||||||
|
rm -f "$REVIEW_INJECT_TMP"
|
||||||
|
log "review question from ${SENDER} injected into ${REVIEW_SESSION}"
|
||||||
|
matrix_send "review" "✓ question forwarded to reviewer session" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
log "review session ${REVIEW_SESSION} not found for PR #${REVIEW_PR_NUM}"
|
||||||
|
matrix_send "review" "review session not active for PR #${REVIEW_PR_NUM}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "review thread ${THREAD_ROOT:0:20} has no PR mapping"
|
||||||
|
matrix_send "review" "review session not available" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
vault)
|
vault)
|
||||||
# Parse APPROVE <id> or REJECT <id> from reply
|
# Parse APPROVE <id> or REJECT <id> from reply
|
||||||
VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)
|
VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,13 @@ source "$(dirname "$0")/../lib/env.sh"
|
||||||
|
|
||||||
|
|
||||||
REPO="${CODEBERG_REPO}"
|
REPO="${CODEBERG_REPO}"
|
||||||
|
REPO_ROOT="${PROJECT_REPO_ROOT}"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
API_BASE="${CODEBERG_API}"
|
API_BASE="${CODEBERG_API}"
|
||||||
LOGFILE="$SCRIPT_DIR/review.log"
|
LOGFILE="$SCRIPT_DIR/review.log"
|
||||||
MAX_REVIEWS=3
|
MAX_REVIEWS=3
|
||||||
|
REVIEW_IDLE_TIMEOUT=14400 # 4h: kill review session if idle
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||||
|
|
@ -34,6 +36,40 @@ fi
|
||||||
|
|
||||||
log "--- Poll start ---"
|
log "--- Poll start ---"
|
||||||
|
|
||||||
|
# --- Clean up stale review sessions ---
|
||||||
|
# Kill sessions for merged/closed PRs or idle > 4h
|
||||||
|
REVIEW_SESSIONS=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^review-${PROJECT_NAME}-" || true)
|
||||||
|
if [ -n "$REVIEW_SESSIONS" ]; then
|
||||||
|
while IFS= read -r session; do
|
||||||
|
pr_num="${session#review-"${PROJECT_NAME}"-}"
|
||||||
|
phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase"
|
||||||
|
|
||||||
|
# Check if PR is still open
|
||||||
|
pr_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
||||||
|
"${API_BASE}/pulls/${pr_num}" | jq -r '.state // "unknown"' 2>/dev/null) || true
|
||||||
|
|
||||||
|
if [ "$pr_state" != "open" ]; then
|
||||||
|
log "cleanup: killing session ${session} (PR #${pr_num} state=${pr_state})"
|
||||||
|
tmux kill-session -t "$session" 2>/dev/null || true
|
||||||
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||||
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check idle timeout (4h)
|
||||||
|
phase_mtime=$(stat -c %Y "$phase_file" 2>/dev/null || echo 0)
|
||||||
|
now=$(date +%s)
|
||||||
|
if [ "$phase_mtime" -gt 0 ] && [ $(( now - phase_mtime )) -gt "$REVIEW_IDLE_TIMEOUT" ]; then
|
||||||
|
log "cleanup: killing session ${session} (idle > 4h)"
|
||||||
|
tmux kill-session -t "$session" 2>/dev/null || true
|
||||||
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
done <<< "$REVIEW_SESSIONS"
|
||||||
|
fi
|
||||||
|
|
||||||
PRS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
PRS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
||||||
"${API_BASE}/pulls?state=open&limit=20" | \
|
"${API_BASE}/pulls?state=open&limit=20" | \
|
||||||
jq -r --arg branch "${PRIMARY_BRANCH}" '.[] | select(.base.ref == $branch) | select(.draft != true) | select(.title | test("^\\[?WIP[\\]:]"; "i") | not) | "\(.number) \(.head.sha) \(.head.ref)"')
|
jq -r --arg branch "${PRIMARY_BRANCH}" '.[] | select(.base.ref == $branch) | select(.draft != true) | select(.title | test("^\\[?WIP[\\]:]"; "i") | not) | "\(.number) \(.head.sha) \(.head.ref)"')
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# review-pr.sh — AI-powered PR review using claude CLI
|
# review-pr.sh — AI-powered PR review using persistent Claude tmux session
|
||||||
#
|
#
|
||||||
# Usage: ./review-pr.sh <pr-number> [--force]
|
# Usage: ./review-pr.sh <pr-number> [--force]
|
||||||
#
|
#
|
||||||
# Features:
|
# Session lifecycle:
|
||||||
# - Full review on first pass
|
# 1. Creates/reuses tmux session: review-{project}-{pr}
|
||||||
# - Incremental re-review when previous review exists (verifies findings addressed)
|
# 2. Injects PR diff + review guidelines into interactive claude
|
||||||
# - Auto-creates follow-up issues for pre-existing bugs flagged by reviewer
|
# 3. Claude reviews, writes structured JSON to output file
|
||||||
# - JSON output format with validation + retry
|
# 4. Script posts review to Codeberg
|
||||||
|
# 5. Session stays alive for re-reviews and human questions
|
||||||
#
|
#
|
||||||
# Peek while running: cat /tmp/<project>-review-status
|
# Re-review (new commits pushed):
|
||||||
# Watch log: tail -f <factory-root>/review/review.log
|
# 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/<project>-review-status
|
||||||
|
# Log: tail -f <factory-root>/review/review.log
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -36,6 +43,14 @@ MAX_DIFF=25000
|
||||||
MAX_ATTEMPTS=2
|
MAX_ATTEMPTS=2
|
||||||
TMPDIR=$(mktemp -d)
|
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() {
|
log() {
|
||||||
printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"
|
printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +63,7 @@ status() {
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$TMPDIR"
|
rm -rf "$TMPDIR"
|
||||||
rm -f "$LOCKFILE" "$STATUSFILE"
|
rm -f "$LOCKFILE" "$STATUSFILE"
|
||||||
|
# tmux session persists for re-reviews and human questions
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
|
@ -76,7 +92,56 @@ if [ -f "$LOCKFILE" ]; then
|
||||||
fi
|
fi
|
||||||
echo $$ > "$LOCKFILE"
|
echo $$ > "$LOCKFILE"
|
||||||
|
|
||||||
# Fetch PR metadata
|
# --- Tmux session helpers ---
|
||||||
|
wait_for_claude_ready() {
|
||||||
|
local timeout="${1:-120}"
|
||||||
|
local elapsed=0
|
||||||
|
while [ "$elapsed" -lt "$timeout" ]; do
|
||||||
|
if tmux capture-pane -t "${SESSION_NAME}" -p 2>/dev/null | grep -q '❯'; 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
|
||||||
|
tmpfile=$(mktemp /tmp/review-inject-XXXXXX)
|
||||||
|
printf '%s' "$text" > "$tmpfile"
|
||||||
|
tmux load-buffer -b "review-inject-${PR_NUMBER}" "$tmpfile"
|
||||||
|
tmux paste-buffer -t "${SESSION_NAME}" -b "review-inject-${PR_NUMBER}"
|
||||||
|
sleep 0.5
|
||||||
|
tmux send-keys -t "${SESSION_NAME}" "" Enter
|
||||||
|
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
|
||||||
|
sleep "$REVIEW_WAIT_INTERVAL"
|
||||||
|
elapsed=$((elapsed + REVIEW_WAIT_INTERVAL))
|
||||||
|
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
|
||||||
|
done
|
||||||
|
log "ERROR: review did not complete within ${timeout}s"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Fetch PR metadata ---
|
||||||
status "fetching metadata"
|
status "fetching metadata"
|
||||||
PR_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
PR_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
||||||
"${API_BASE}/pulls/${PR_NUMBER}")
|
"${API_BASE}/pulls/${PR_NUMBER}")
|
||||||
|
|
@ -93,8 +158,11 @@ log "${PR_TITLE} (${PR_HEAD}→${PR_BASE} ${PR_SHA:0:7})"
|
||||||
if [ "$PR_STATE" != "open" ]; then
|
if [ "$PR_STATE" != "open" ]; then
|
||||||
log "SKIP: state=${PR_STATE}"
|
log "SKIP: state=${PR_STATE}"
|
||||||
cd "$REPO_ROOT"
|
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
|
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 -rf "/tmp/${PROJECT_NAME}-review-${PR_NUMBER}" 2>/dev/null || true
|
||||||
|
rm -f "${PHASE_FILE}" "${REVIEW_OUTPUT_FILE}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -366,7 +434,13 @@ ${DIFF}
|
||||||
Review the incremental diff. For each finding in the previous review, check if it was addressed.
|
Review the incremental diff. For each finding in the previous review, check if it was addressed.
|
||||||
Then check for new issues introduced by the fix.
|
Then check for new issues introduced by the fix.
|
||||||
|
|
||||||
## OUTPUT FORMAT — MANDATORY
|
## OUTPUT — MANDATORY
|
||||||
|
Write your review as a single JSON object to this file: ${REVIEW_OUTPUT_FILE}
|
||||||
|
After writing the file, signal completion by running this exact command:
|
||||||
|
echo "PHASE:review_complete" > "${PHASE_FILE}"
|
||||||
|
Then STOP and wait for further instructions. The orchestrator will post your review.
|
||||||
|
|
||||||
|
The JSON must follow this exact schema:
|
||||||
${JSON_SCHEMA_REREVIEW}
|
${JSON_SCHEMA_REREVIEW}
|
||||||
INCR_EOF
|
INCR_EOF
|
||||||
|
|
||||||
|
|
@ -392,7 +466,13 @@ ${DIFF}
|
||||||
## Your Task
|
## Your Task
|
||||||
${TASK_DESC}
|
${TASK_DESC}
|
||||||
|
|
||||||
## OUTPUT FORMAT — MANDATORY
|
## OUTPUT — MANDATORY
|
||||||
|
Write your review as a single JSON object to this file: ${REVIEW_OUTPUT_FILE}
|
||||||
|
After writing the file, signal completion by running this exact command:
|
||||||
|
echo "PHASE:review_complete" > "${PHASE_FILE}"
|
||||||
|
Then STOP and wait for further instructions. The orchestrator will post your review.
|
||||||
|
|
||||||
|
The JSON must follow this exact schema:
|
||||||
${JSON_SCHEMA_FRESH}
|
${JSON_SCHEMA_FRESH}
|
||||||
DIFF_EOF
|
DIFF_EOF
|
||||||
fi
|
fi
|
||||||
|
|
@ -400,76 +480,104 @@ fi
|
||||||
PROMPT_SIZE=$(stat -c%s "${TMPDIR}/prompt.md")
|
PROMPT_SIZE=$(stat -c%s "${TMPDIR}/prompt.md")
|
||||||
log "Prompt: ${PROMPT_SIZE} bytes (re-review: ${IS_RE_REVIEW})"
|
log "Prompt: ${PROMPT_SIZE} bytes (re-review: ${IS_RE_REVIEW})"
|
||||||
|
|
||||||
# --- Run claude with retry on invalid JSON ---
|
# ==========================================================================
|
||||||
CONTINUE_FLAG=""
|
# CREATE / REUSE TMUX SESSION
|
||||||
if [ "$IS_RE_REVIEW" = true ]; then
|
# ==========================================================================
|
||||||
CONTINUE_FLAG="-c"
|
status "preparing tmux session: ${SESSION_NAME}"
|
||||||
|
|
||||||
|
if ! tmux has-session -t "${SESSION_NAME}" 2>/dev/null; then
|
||||||
|
# Kill any stale entry
|
||||||
|
tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create new detached session running interactive claude in the review worktree
|
||||||
|
tmux new-session -d -s "${SESSION_NAME}" -c "${REVIEW_WORKTREE}" \
|
||||||
|
"claude --model sonnet --dangerously-skip-permissions"
|
||||||
|
|
||||||
|
if ! tmux has-session -t "${SESSION_NAME}" 2>/dev/null; then
|
||||||
|
log "ERROR: failed to create tmux session ${SESSION_NAME}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for Claude to be ready (polls for prompt)
|
||||||
|
if ! wait_for_claude_ready 120; then
|
||||||
|
log "ERROR: claude not ready in ${SESSION_NAME}"
|
||||||
|
tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "tmux session created: ${SESSION_NAME}"
|
||||||
|
else
|
||||||
|
log "reusing existing tmux session: ${SESSION_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Clear previous review output and phase signal
|
||||||
|
rm -f "${REVIEW_OUTPUT_FILE}" "${PHASE_FILE}"
|
||||||
|
|
||||||
|
# Inject prompt into session
|
||||||
|
inject_into_session "$(cat "${TMPDIR}/prompt.md")"
|
||||||
|
log "prompt injected into tmux session"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# WAIT FOR REVIEW OUTPUT (with retry on invalid JSON)
|
||||||
|
# ==========================================================================
|
||||||
REVIEW_JSON=""
|
REVIEW_JSON=""
|
||||||
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
|
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
|
||||||
status "running claude attempt ${attempt}/${MAX_ATTEMPTS}"
|
status "waiting for review output (attempt ${attempt}/${MAX_ATTEMPTS})"
|
||||||
SECONDS=0
|
SECONDS=0
|
||||||
|
|
||||||
RAW_OUTPUT=$(cd "$REVIEW_WORKTREE" && claude -p $CONTINUE_FLAG \
|
if wait_for_review_output; then
|
||||||
--model sonnet \
|
ELAPSED=$SECONDS
|
||||||
--dangerously-skip-permissions \
|
|
||||||
--output-format text \
|
|
||||||
< "${TMPDIR}/prompt.md" 2>"${TMPDIR}/claude-stderr.log") || true
|
|
||||||
|
|
||||||
ELAPSED=$SECONDS
|
if [ -f "${REVIEW_OUTPUT_FILE}" ]; then
|
||||||
|
RAW_OUTPUT=$(cat "${REVIEW_OUTPUT_FILE}")
|
||||||
|
RAW_SIZE=$(printf '%s' "$RAW_OUTPUT" | wc -c)
|
||||||
|
log "attempt ${attempt}: ${RAW_SIZE} bytes in ${ELAPSED}s"
|
||||||
|
|
||||||
if [ -z "$RAW_OUTPUT" ]; then
|
# Try raw JSON first
|
||||||
log "attempt ${attempt}: empty output after ${ELAPSED}s"
|
if printf '%s' "$RAW_OUTPUT" | jq -e '.verdict' > /dev/null 2>&1; then
|
||||||
continue
|
REVIEW_JSON="$RAW_OUTPUT"
|
||||||
fi
|
else
|
||||||
|
# Try extracting from code fence
|
||||||
RAW_SIZE=$(printf '%s' "$RAW_OUTPUT" | wc -c)
|
EXTRACTED=$(printf '%s' "$RAW_OUTPUT" | sed -n '/^```json/,/^```$/p' | sed '1d;$d')
|
||||||
log "attempt ${attempt}: ${RAW_SIZE} bytes in ${ELAPSED}s"
|
if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.verdict' > /dev/null 2>&1; then
|
||||||
|
REVIEW_JSON="$EXTRACTED"
|
||||||
# Extract JSON — claude might wrap it in ```json ... ``` or add preamble
|
else
|
||||||
# Try raw first, then extract from code fence
|
# Try extracting first { ... } block
|
||||||
if printf '%s' "$RAW_OUTPUT" | jq -e '.verdict' > /dev/null 2>&1; then
|
EXTRACTED=$(printf '%s' "$RAW_OUTPUT" | sed -n '/^{/,/^}/p')
|
||||||
REVIEW_JSON="$RAW_OUTPUT"
|
if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.verdict' > /dev/null 2>&1; then
|
||||||
else
|
REVIEW_JSON="$EXTRACTED"
|
||||||
# Try extracting from code fence
|
fi
|
||||||
EXTRACTED=$(printf '%s' "$RAW_OUTPUT" | sed -n '/^```json/,/^```$/p' | sed '1d;$d')
|
fi
|
||||||
if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.verdict' > /dev/null 2>&1; then
|
|
||||||
REVIEW_JSON="$EXTRACTED"
|
|
||||||
else
|
|
||||||
# Try extracting first { ... } block
|
|
||||||
EXTRACTED=$(printf '%s' "$RAW_OUTPUT" | sed -n '/^{/,/^}/p')
|
|
||||||
if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.verdict' > /dev/null 2>&1; then
|
|
||||||
REVIEW_JSON="$EXTRACTED"
|
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$REVIEW_JSON" ]; then
|
if [ -n "$REVIEW_JSON" ]; then
|
||||||
# Validate required fields
|
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict // empty')
|
||||||
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict // empty')
|
if [ -n "$VERDICT" ]; then
|
||||||
if [ -n "$VERDICT" ]; then
|
log "attempt ${attempt}: valid JSON, verdict=${VERDICT}"
|
||||||
log "attempt ${attempt}: valid JSON, verdict=${VERDICT}"
|
break
|
||||||
break
|
else
|
||||||
|
log "attempt ${attempt}: JSON missing verdict"
|
||||||
|
REVIEW_JSON=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "attempt ${attempt}: no valid JSON found in output file"
|
||||||
|
printf '%s' "$RAW_OUTPUT" > "${LOGDIR}/review-pr${PR_NUMBER}-raw-attempt-${attempt}.txt"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
log "attempt ${attempt}: JSON missing verdict"
|
log "attempt ${attempt}: output file not found after ${ELAPSED}s"
|
||||||
REVIEW_JSON=""
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "attempt ${attempt}: no valid JSON found in output"
|
ELAPSED=$SECONDS
|
||||||
# Save raw output for debugging
|
log "attempt ${attempt}: timeout or session died after ${ELAPSED}s"
|
||||||
printf '%s' "$RAW_OUTPUT" > "${TMPDIR}/raw-attempt-${attempt}.txt"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For retry, add explicit correction to prompt
|
# For retry, inject correction into session
|
||||||
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
|
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
|
||||||
cat >> "${TMPDIR}/prompt.md" << RETRY_EOF
|
rm -f "${PHASE_FILE}"
|
||||||
|
inject_into_session "RETRY — Your previous review output was not valid JSON.
|
||||||
## RETRY — Your previous response was not valid JSON
|
You MUST write a single JSON object (with a \"verdict\" field) to: ${REVIEW_OUTPUT_FILE}
|
||||||
You MUST output a single JSON object with a "verdict" field. No markdown wrapping. No prose.
|
Then signal: echo \"PHASE:review_complete\" > \"${PHASE_FILE}\"
|
||||||
Start your response with { and end with }.
|
Start the JSON with { and end with }. No markdown wrapping. No prose outside the JSON."
|
||||||
RETRY_EOF
|
log "retry instruction injected"
|
||||||
log "appended retry instruction to prompt"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -477,10 +585,10 @@ done
|
||||||
if [ -z "$REVIEW_JSON" ]; then
|
if [ -z "$REVIEW_JSON" ]; then
|
||||||
log "ERROR: no valid JSON after ${MAX_ATTEMPTS} attempts"
|
log "ERROR: no valid JSON after ${MAX_ATTEMPTS} attempts"
|
||||||
|
|
||||||
ERROR_BODY="## 🤖 AI Review — Error
|
ERROR_BODY="## AI Review — Error
|
||||||
<!-- review-error: ${PR_SHA} -->
|
<!-- review-error: ${PR_SHA} -->
|
||||||
|
|
||||||
⚠️ Review failed: could not produce structured output after ${MAX_ATTEMPTS} attempts.
|
Review failed: could not produce structured output after ${MAX_ATTEMPTS} attempts.
|
||||||
|
|
||||||
A maintainer should review this PR manually, or re-trigger with \`--force\`.
|
A maintainer should review this PR manually, or re-trigger with \`--force\`.
|
||||||
|
|
||||||
|
|
@ -498,16 +606,16 @@ A maintainer should review this PR manually, or re-trigger with \`--force\`.
|
||||||
--data-binary @"${TMPDIR}/comment.json" > /dev/null
|
--data-binary @"${TMPDIR}/comment.json" > /dev/null
|
||||||
|
|
||||||
# Save raw outputs for debugging
|
# Save raw outputs for debugging
|
||||||
for f in "${TMPDIR}"/raw-attempt-*.txt; do
|
for f in "${LOGDIR}"/review-pr"${PR_NUMBER}"-raw-attempt-*.txt; do
|
||||||
[ -f "$f" ] && cp "$f" "${LOGDIR}/review-pr${PR_NUMBER}-$(basename "$f")"
|
[ -f "$f" ] && log "raw output saved: $f"
|
||||||
done
|
done
|
||||||
|
|
||||||
matrix_send "review" "⚠️ PR #${PR_NUMBER} review failed — no valid JSON output" 2>/dev/null || true
|
matrix_send "review" "PR #${PR_NUMBER} review failed — no valid JSON output" 2>/dev/null || true
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Render JSON → Markdown ---
|
# --- Render JSON -> Markdown ---
|
||||||
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict')
|
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict')
|
||||||
VERDICT_REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
VERDICT_REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
||||||
|
|
||||||
|
|
@ -523,19 +631,19 @@ render_markdown() {
|
||||||
if [ "$prev_count" -gt 0 ]; then
|
if [ "$prev_count" -gt 0 ]; then
|
||||||
md+="### Previous Findings"$'\n'
|
md+="### Previous Findings"$'\n'
|
||||||
while IFS= read -r finding; do
|
while IFS= read -r finding; do
|
||||||
local summary status explanation
|
local summary finding_status explanation
|
||||||
summary=$(printf '%s' "$finding" | jq -r '.summary')
|
summary=$(printf '%s' "$finding" | jq -r '.summary')
|
||||||
status=$(printf '%s' "$finding" | jq -r '.status')
|
finding_status=$(printf '%s' "$finding" | jq -r '.status')
|
||||||
explanation=$(printf '%s' "$finding" | jq -r '.explanation')
|
explanation=$(printf '%s' "$finding" | jq -r '.explanation')
|
||||||
|
|
||||||
local icon="❓"
|
local icon="?"
|
||||||
case "$status" in
|
case "$finding_status" in
|
||||||
fixed) icon="✅" ;;
|
fixed) icon="FIXED" ;;
|
||||||
not_fixed) icon="❌" ;;
|
not_fixed) icon="NOT FIXED" ;;
|
||||||
partial) icon="⚠️" ;;
|
partial) icon="PARTIAL" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
md+="- ${summary} → ${icon} ${explanation}"$'\n'
|
md+="- ${summary} -> ${icon} ${explanation}"$'\n'
|
||||||
done < <(printf '%s' "$json" | jq -c '.previous_findings[]')
|
done < <(printf '%s' "$json" | jq -c '.previous_findings[]')
|
||||||
md+=$'\n'
|
md+=$'\n'
|
||||||
fi
|
fi
|
||||||
|
|
@ -550,14 +658,7 @@ render_markdown() {
|
||||||
loc=$(printf '%s' "$issue" | jq -r '.location')
|
loc=$(printf '%s' "$issue" | jq -r '.location')
|
||||||
desc=$(printf '%s' "$issue" | jq -r '.description')
|
desc=$(printf '%s' "$issue" | jq -r '.description')
|
||||||
|
|
||||||
local icon="ℹ️"
|
md+="- **${sev}** \`${loc}\`: ${desc}"$'\n'
|
||||||
case "$sev" in
|
|
||||||
bug) icon="🐛" ;;
|
|
||||||
warning) icon="⚠️" ;;
|
|
||||||
nit) icon="💅" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
md+="- ${icon} **${sev}** \`${loc}\`: ${desc}"$'\n'
|
|
||||||
done < <(printf '%s' "$json" | jq -c '.new_issues[]')
|
done < <(printf '%s' "$json" | jq -c '.new_issues[]')
|
||||||
md+=$'\n'
|
md+=$'\n'
|
||||||
fi
|
fi
|
||||||
|
|
@ -581,14 +682,7 @@ render_markdown() {
|
||||||
loc=$(printf '%s' "$finding" | jq -r '.location')
|
loc=$(printf '%s' "$finding" | jq -r '.location')
|
||||||
desc=$(printf '%s' "$finding" | jq -r '.description')
|
desc=$(printf '%s' "$finding" | jq -r '.description')
|
||||||
|
|
||||||
local icon="ℹ️"
|
md+="- **${sev}** \`${loc}\`: ${desc}"$'\n'
|
||||||
case "$sev" in
|
|
||||||
bug) icon="🐛" ;;
|
|
||||||
warning) icon="⚠️" ;;
|
|
||||||
nit) icon="💅" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
md+="- ${icon} **${sev}** \`${loc}\`: ${desc}"$'\n'
|
|
||||||
done < <(printf '%s' "$section" | jq -c '.findings[]')
|
done < <(printf '%s' "$section" | jq -c '.findings[]')
|
||||||
md+=$'\n'
|
md+=$'\n'
|
||||||
fi
|
fi
|
||||||
|
|
@ -627,13 +721,13 @@ if [ "$IS_RE_REVIEW" = true ]; then
|
||||||
REVIEW_TYPE="Re-review (round ${ROUND})"
|
REVIEW_TYPE="Re-review (round ${ROUND})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMMENT_BODY="## 🤖 AI ${REVIEW_TYPE}
|
COMMENT_BODY="## AI ${REVIEW_TYPE}
|
||||||
<!-- reviewed: ${PR_SHA} -->
|
<!-- reviewed: ${PR_SHA} -->
|
||||||
|
|
||||||
${REVIEW_MD}
|
${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)*"
|
*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"
|
printf '%s' "$COMMENT_BODY" > "${TMPDIR}/comment-body.txt"
|
||||||
jq -Rs '{body: .}' < "${TMPDIR}/comment-body.txt" > "${TMPDIR}/comment.json"
|
jq -Rs '{body: .}' < "${TMPDIR}/comment-body.txt" > "${TMPDIR}/comment.json"
|
||||||
|
|
@ -741,7 +835,10 @@ ${FU_DETAILS}
|
||||||
log "created ${CREATED_COUNT} follow-up issues total"
|
log "created ${CREATED_COUNT} follow-up issues total"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Notify Matrix ---
|
# --- Notify Matrix (with thread mapping for human questions) ---
|
||||||
matrix_send "review" "🤖 PR #${PR_NUMBER} ${REVIEW_TYPE}: ${VERDICT} — ${PR_TITLE}" 2>/dev/null || true
|
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} (${ELAPSED}s, re-review: ${IS_RE_REVIEW})"
|
log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue