#!/usr/bin/env bash # Claude PR-review-comment watcher -- Linux port (runs on pms1 via cron). # # Sibling to claude-issue-watcher.sh. Where that watcher turns *issues* into PRs, # this one addresses *review comments* left on the PRs Claude already raised. # # Per run: # 1. List open PRs that Claude raised (head branch starts with prBranchPrefix, # or labelled `claude-pr`). # 2. On each, collect every comment carrying the marker `claude-review:` -- # from the PR conversation, from review summaries, and from inline (on-file) # review comments -- but ONLY from repo collaborators (write access). # 3. Skip comments already handled in a previous run (tracked by a hidden marker # in the bot's acknowledgement comments). # 4. Run headless Claude Code on the PR's own branch with those instructions; # it edits + verifies, the watcher pushes the new commit(s) to the SAME branch # (updating the PR in place), then acknowledges each comment (reply + reaction). # # Config: pr-review-watcher.config.json next to this script (or pass a path as $1). # See automation/README.md > "PR review-comment watcher". set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="${1:-$SCRIPT_DIR/pr-review-watcher.config.json}" [ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (copy pr-review-watcher.config.example.json and fill in the token)"; exit 1; } cfg() { jq -r "$1" "$CONFIG"; } FORGEJO_URL=$(cfg .forgejoUrl) REPO=$(cfg .repo) TOKEN=$(cfg .token) WORKDIR=$(cfg .workDir) BASE_BRANCH=$(cfg .baseBranch) PR_BRANCH_PREFIX=$(cfg '.prBranchPrefix // "claude/"') MARKER=$(cfg '.marker // "claude-review:"') MAX_PRS=$(cfg '.maxPrsPerRun // 1') MAX_COMMENTS=$(cfg '.maxCommentsPerPr // 20') CLAUDE=$(cfg .claudeExe) TURNS=$(cfg '.claudeMaxTurns // 150') # Hard wall-clock cap on a single Claude run. --max-turns bounds turns but a single # stuck turn (or a server Claude spawned) can still block forever -- which under cron # would hold the flock lock and freeze every later run. `timeout` guarantees control # returns to the supervisor so it can still push partial work + write the handled marker. CLAUDE_TIMEOUT=$(cfg '.claudeTimeout // "30m"') # Ephemeral dev-server port for Claude's runtime checks. DISTINCT from the issue # watcher's 3100 so the two never collide if their cron runs overlap (3000=prod, # 3100=autofix/issue-watcher, 3200=staging, 3101=this watcher). DEV_PORT=$(cfg '.devPort // 3101') API="$FORGEJO_URL/api/v1" # Hidden marker the bot stamps on its acknowledgement comments. The "handled:" # line lists every comment key it has addressed, so subsequent runs skip them. HANDLED_TAG='ppms-review-bot handled:' ACK_REACTION='rocket' LOG_DIR="$SCRIPT_DIR/logs" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/pr-review-$(date +%F).log" log() { echo "$(date +%T) $*" | tee -a "$LOG_FILE"; } # --- single-instance lock (separate from the issue watcher's) --- exec 9>"$SCRIPT_DIR/.pr-review-watcher.lock" if ! flock -n 9; then log "Another PR-review watcher run is active; exiting."; exit 0; fi # --- preflight: idle until Claude Code is authenticated on this host --- if [ ! -f "$HOME/.claude/.credentials.json" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then log "Claude Code not authenticated yet (no ~/.claude/.credentials.json or ANTHROPIC_API_KEY); skipping." exit 0 fi # --- Forgejo API helpers (curl + jq) --- api() { # METHOD PATH [JSON_BODY] local method=$1 path=$2 body=${3:-} if [ -n "$body" ]; then curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" --data "$body" else curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" fi } # Soft variant: never aborts the run on a single failed call (e.g. reactions # unsupported on a given comment type). Returns empty + logs instead. api_soft() { api "$@" 2>/dev/null || { log "api_soft: $1 $2 failed (ignored)"; printf ''; }; } add_pr_comment() { # NUMBER TEXT api POST "/repos/$REPO/issues/$1/comments" "$(jq -nc --arg b "$2" '{body:$b}')" >/dev/null } react() { # COMMENT_ID (PR-conversation comments only; best-effort) api_soft POST "/repos/$REPO/issues/comments/$1/reactions" \ "$(jq -nc --arg c "$ACK_REACTION" '{content:$c}')" >/dev/null } # --- prepare the dedicated work clone --- host_no_scheme=$(printf '%s' "$FORGEJO_URL" | sed 's#^https\?://##') owner=${REPO%%/*} CLONE_URL="http://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git" [ "${FORGEJO_URL#https}" != "$FORGEJO_URL" ] && CLONE_URL="https://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git" if [ ! -d "$WORKDIR/.git" ]; then log "Cloning $REPO into $WORKDIR" if ! git clone -q "$CLONE_URL" "$WORKDIR"; then log "git clone failed"; exit 1; fi git -C "$WORKDIR" config user.name "Claude (review-bot)" git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com" fi # --- authorization set --- # Collaborators = users with write access. The repo owner is always allowed. # (The bot may post as the owner's account, so we never filter by author to spot # the bot's own comments -- its acknowledgements are excluded by the HANDLED_TAG # marker instead, and human acks lack the claude-review: marker anyway.) COLLAB=$(api GET "/repos/$REPO/collaborators?limit=100" \ | jq -c --arg owner "$owner" '[.[].login] + [$owner] | unique') log "Authorized commenters: $(printf '%s' "$COLLAB" | jq -r 'join(", ")')" # --- find Claude-raised open PRs (head branch under the prefix, or labelled claude-pr) --- prs=$(api GET "/repos/$REPO/pulls?state=open&limit=50" \ | jq -c --arg pfx "$PR_BRANCH_PREFIX" \ '[ .[] | select((.head.ref | startswith($pfx)) or (((.labels//[])|map(.name))|index("claude-pr"))) ] | sort_by(.number)') # Scan ALL matching PRs (not truncated) -- the per-run cap below limits only how # many PRs Claude actually RUNS on, so comment-less PRs never crowd out newer ones. n_prs=$(printf '%s' "$prs" | jq 'length') log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' comments (will run Claude on up to $MAX_PRS with new comments)" # Pull the instruction text that follows the marker out of a comment body. instr_of() { # BODY -> text after the first marker occurrence, trimmed jq -rn --arg b "$1" --arg m "$MARKER" \ '$b | split($m) | .[1:] | join($m) | gsub("^\\s+|\\s+$";"")' } p=0 processed=0 while [ "$p" -lt "$n_prs" ]; do pr=$(printf '%s' "$prs" | jq -c ".[$p]") p=$((p+1)) num=$(printf '%s' "$pr" | jq -r .number) title=$(printf '%s' "$pr" | jq -r .title) branch=$(printf '%s' "$pr" | jq -r .head.ref) log "-- PR #$num ($branch): $title" # ---- gather candidate comments from the three sources ---- conv=$(api GET "/repos/$REPO/issues/$num/comments?limit=100") reviews=$(api GET "/repos/$REPO/pulls/$num/reviews?limit=100") # Keys already addressed in a prior run (scanned from the bot's ack comments). handled=$(printf '%s' "$conv" | jq -c --arg tag "$HANDLED_TAG" \ '[ .[].body // "" | select(contains($tag)) | scan("(?:conv|summary|inline):[0-9]+") ] | unique') # A candidate must carry the marker, NOT be one of the bot's own ack comments # (those carry HANDLED_TAG), and come from an authorized (collaborator) user. sel='select(.body != null) | select(.body | contains($m)) | select(.body | contains($tag) | not) | select(.user.login as $u | ($collab | index($u)))' conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" " [ .[] | $sel | { key:(\"conv:\"+(.id|tostring)), kind:\"conv\", id:.id, user:.user.login, loc:\"PR conversation\", body:.body } ]") summary_tasks=$(printf '%s' "$reviews" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" " [ .[] | select(.body != \"\") | $sel | { key:(\"summary:\"+(.id|tostring)), kind:\"summary\", id:.id, user:.user.login, loc:\"review summary\", body:.body } ]") # Inline (on-file) comments live under each review. inline_tasks='[]' for rid in $(printf '%s' "$reviews" | jq -r '.[].id'); do rc=$(api_soft GET "/repos/$REPO/pulls/$num/reviews/$rid/comments") [ -z "$rc" ] && continue t=$(printf '%s' "$rc" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" " [ .[] | $sel | { key:(\"inline:\"+(.id|tostring)), kind:\"inline\", id:.id, user:.user.login, loc:(\"inline \"+(.path//\"?\")+\":\"+((.line // .original_line // 0)|tostring)), hunk:(.diff_hunk // \"\"), body:.body } ]") inline_tasks=$(jq -nc --argjson a "$inline_tasks" --argjson b "$t" '$a + $b') done all=$(jq -nc --argjson a "$conv_tasks" --argjson b "$summary_tasks" --argjson c "$inline_tasks" '$a + $b + $c') fresh=$(printf '%s' "$all" | jq -c --argjson h "$handled" '[ .[] | select(.key as $k | ($h|index($k)) | not) ]') fresh=$(printf '%s' "$fresh" | jq -c ".[:$MAX_COMMENTS]") n=$(printf '%s' "$fresh" | jq 'length') if [ "$n" -eq 0 ]; then log " no new '$MARKER' comments"; continue; fi if [ "$processed" -ge "$MAX_PRS" ]; then log " $n new '$MARKER' comment(s) but per-run cap ($MAX_PRS) reached; deferring PR #$num to next run" continue fi processed=$((processed+1)) log " $n new '$MARKER' comment(s) to address (PR $processed/$MAX_PRS this run)" # ---- check out the PR branch in the work clone ---- git -C "$WORKDIR" fetch origin -q if ! git -C "$WORKDIR" checkout -B "$branch" "origin/$branch" -q 2>>"$LOG_FILE"; then log " checkout of origin/$branch failed; skipping PR #$num"; continue fi git -C "$WORKDIR" clean -fdq # ---- build the prompt ---- keys=$(printf '%s' "$fresh" | jq -r '[.[].key] | join(" ")') prompt_file=$(mktemp) { printf '%s\n' "You are addressing REVIEW COMMENTS on PR #$num of the Pelagia Portal (PPMS), a Next.js 15" printf '%s\n' "purchase-order management system. The web app lives in App/ -- read App/CLAUDE.md first." printf '%s\n' "You are already checked out on the PR branch '$branch'. Inspect what the PR changed with:" printf '%s\n' " git -C . log --oneline origin/$BASE_BRANCH..HEAD && git diff origin/$BASE_BRANCH...HEAD" printf '\n## PR #%s: %s\n\n' "$num" "$title" printf '%s\n\n' "## Review comments to address (each begins with '$MARKER')" i=0 while [ "$i" -lt "$n" ]; do item=$(printf '%s' "$fresh" | jq -c ".[$i]") i=$((i+1)) u=$(printf '%s' "$item" | jq -r .user) loc=$(printf '%s' "$item" | jq -r .loc) body=$(printf '%s' "$item" | jq -r .body) hunk=$(printf '%s' "$item" | jq -r '.hunk // ""') instr=$(instr_of "$body") printf '### Comment %s -- %s (by %s)\n' "$i" "$loc" "$u" if [ -n "$hunk" ] && [ "$hunk" != "null" ]; then printf 'Code under review:\n```\n%s\n```\n' "$hunk" fi printf 'Instruction: %s\n\n' "$instr" done printf '%s\n' "## Test environment available to you" printf '%s\n' "- App/.env points DATABASE_URL at a TEST database (pelagia_test) -- a daily mirror of" printf '%s\n' " production, safe to read and write. It is NOT production. Email is console-logged and" printf '%s\n' " storage is local in this dev mode." printf '%s\n' "- Run integration tests after loading the env:" printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration" printf '%s\n' "- If you need runtime verification you MAY start a dev server ON PORT $DEV_PORT ONLY:" printf '%s\n' " cd App && pnpm dev -p $DEV_PORT (production runs on 3000 -- NEVER touch 3000)" printf '%s\n' " Stop ONLY your own server by port ('fuser -k $DEV_PORT/tcp'); NEVER a broad 'pkill -f next'." printf '%s\n' "" printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)" printf '%s\n' "1. Make the focused changes the review comments ask for -- nothing more." printf '%s\n' "2. If you change code under App/app|lib|components|hooks, add or update a test (the PR check" printf '%s\n' " rejects code changes with no test change). Model integration tests on" printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts." printf '%s\n' "3. Verify: 'cd App && pnpm type-check' (no new errors); run relevant tests." printf '%s\n' "4. Update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/, CHANGELOG.md)." printf '%s\n' "5. Commit ALL changes to the current branch with a conventional message referencing #$num." printf '%s\n' "6. Do NOT push, do NOT switch branches, do NOT open/close PRs. The supervisor pushes." printf '%s\n' "7. BEFORE you finish: stop any dev server you started ('fuser -k $DEV_PORT/tcp'), leave NO" printf '%s\n' " background process running, and then END YOUR TURN. Do not wait or keep the session open." printf '%s\n' "If a comment is unclear, out of scope, or too risky to do unattended (migrations, payments," printf '%s\n' "permissions), make NO commit for it and explain why in CLAUDE_RESULT.md in the repo root." } > "$prompt_file" clog="$LOG_DIR/claude-pr-$num-$(date +%Y%m%d-%H%M%S).log" log " Running Claude on PR #$num (log: $clog, timeout: $CLAUDE_TIMEOUT)" # `timeout` sends TERM at the limit, then KILL 30s later, in its own session # (-s/setsid via `setsid`) so the whole process group dies -- not just `claude`, # but any dev server it spawned. Bounded so a stuck run can never wedge the lock. ( cd "$WORKDIR" && setsid timeout -k 30s "$CLAUDE_TIMEOUT" \ "$CLAUDE" -p --dangerously-skip-permissions \ --max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$? if [ "$rc" -eq 124 ]; then log " Claude TIMED OUT after $CLAUDE_TIMEOUT on PR #$num (rc=124) -- continuing with any committed work" else log " Claude exited with code $rc for PR #$num" fi # Backstop: reap a dev server the run may have left on this watcher's dev port. fuser -k "$DEV_PORT/tcp" >/dev/null 2>&1 || true rm -f "$prompt_file" note="" if [ -f "$WORKDIR/CLAUDE_RESULT.md" ]; then note=$(cat "$WORKDIR/CLAUDE_RESULT.md") rm -f "$WORKDIR/CLAUDE_RESULT.md" git -C "$WORKDIR" checkout -- . 2>/dev/null fi # ---- build the acknowledgement (lists handled keys + quotes each comment) ---- ack_items=$(printf '%s' "$fresh" | jq -r '.[] | "- **\(.loc)** (by \(.user))"') commits=$(git -C "$WORKDIR" rev-list "origin/$branch..HEAD" --count 2>/dev/null || echo 0) if [ "${commits:-0}" -gt 0 ]; then log " Claude made $commits commit(s); pushing to $branch" if ! git -C "$WORKDIR" push -u origin "$branch" -q 2>>"$LOG_FILE"; then log " push failed for PR #$num" add_pr_comment "$num" "[Claude review-bot] Addressed the review comments locally but the push to \`$branch\` failed. See watcher logs on pms1: \`$clog\`. " continue fi body="[Claude review-bot] Addressed the following review comment(s) on \`$branch\` ($commits commit(s) pushed): $ack_items ${note:+ Notes: $note } " add_pr_comment "$num" "$body" # Best-effort reaction on PR-conversation comments (reactions API is keyed # to issue comments; inline/summary review comments are tracked by the marker). for cid in $(printf '%s' "$fresh" | jq -r '.[] | select(.kind=="conv") | .id'); do react "$cid"; done log " PR #$num updated and acknowledged" else log " No commits produced for PR #$num" reason=${note:-"Claude did not produce a change. See watcher logs on pms1: \`$clog\`."} add_pr_comment "$num" "[Claude review-bot] Reviewed the marked comment(s) but produced no change: $reason A human may need to take these: $ack_items " log " PR #$num: no change, acknowledged (marked handled to avoid re-running)" fi done log "PR-review watcher run complete."