Merge pull request 'fix: feat: persistent Claude tmux session for reviewer (#78)' (#101) from fix/issue-78 into main

This commit is contained in:
johba 2026-03-18 01:11:28 +01:00
commit 7cc9ce1b39
3 changed files with 282 additions and 100 deletions

View file

@ -145,6 +145,44 @@ 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}"
REVIEW_PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${REVIEW_PR_NUM}.phase"
if tmux has-session -t "$REVIEW_SESSION" 2>/dev/null; then
# Skip injection if Claude is mid-review (phase file absent = actively writing)
REVIEW_CUR_PHASE=$(head -1 "$REVIEW_PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true)
if [ -z "$REVIEW_CUR_PHASE" ]; then
log "review session ${REVIEW_SESSION} is mid-review, deferring question"
matrix_send "review" "reviewer is busy — question queued, try again shortly" "$THREAD_ROOT" >/dev/null 2>&1 || true
else
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
fi
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)

View file

@ -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,47 @@ 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"
# Prune review-thread-map entries for this PR
sed -i "/\t${pr_num}$/d" /tmp/review-thread-map 2>/dev/null || true
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"
# Prune review-thread-map entries for this PR
sed -i "/\t${pr_num}$/d" /tmp/review-thread-map 2>/dev/null || true
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
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)"')

View file

@ -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,63 @@ 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
# 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" 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 +165,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 +441,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 +473,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,35 +487,56 @@ 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
# 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 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
# 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 \
--dangerously-skip-permissions \
--output-format text \
< "${TMPDIR}/prompt.md" 2>"${TMPDIR}/claude-stderr.log") || true
ELAPSED=$SECONDS ELAPSED=$SECONDS
if [ -z "$RAW_OUTPUT" ]; then if [ -f "${REVIEW_OUTPUT_FILE}" ]; then
log "attempt ${attempt}: empty output after ${ELAPSED}s" RAW_OUTPUT=$(cat "${REVIEW_OUTPUT_FILE}")
continue
fi
RAW_SIZE=$(printf '%s' "$RAW_OUTPUT" | wc -c) RAW_SIZE=$(printf '%s' "$RAW_OUTPUT" | wc -c)
log "attempt ${attempt}: ${RAW_SIZE} bytes in ${ELAPSED}s" log "attempt ${attempt}: ${RAW_SIZE} bytes in ${ELAPSED}s"
# Extract JSON — claude might wrap it in ```json ... ``` or add preamble # Try raw JSON first
# Try raw first, then extract from code fence
if printf '%s' "$RAW_OUTPUT" | jq -e '.verdict' > /dev/null 2>&1; then if printf '%s' "$RAW_OUTPUT" | jq -e '.verdict' > /dev/null 2>&1; then
REVIEW_JSON="$RAW_OUTPUT" REVIEW_JSON="$RAW_OUTPUT"
else else
@ -446,7 +554,6 @@ for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
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}"
@ -456,20 +563,25 @@ for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
REVIEW_JSON="" REVIEW_JSON=""
fi fi
else else
log "attempt ${attempt}: no valid JSON found in output" log "attempt ${attempt}: no valid JSON found in output file"
# Save raw output for debugging printf '%s' "$RAW_OUTPUT" > "${LOGDIR}/review-pr${PR_NUMBER}-raw-attempt-${attempt}.txt"
printf '%s' "$RAW_OUTPUT" > "${TMPDIR}/raw-attempt-${attempt}.txt" fi
else
log "attempt ${attempt}: output file not found after ${ELAPSED}s"
fi
else
ELAPSED=$SECONDS
log "attempt ${attempt}: timeout or session died after ${ELAPSED}s"
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 +589,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 +610,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 +635,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 +662,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 +686,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 +725,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 +839,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})"