Sibling to claude-issue-watcher.sh: polls open Claude-raised PRs (head branch under claude/, or labelled claude-pr) for review comments carrying the marker `claude-review:` — in the PR conversation, review summaries, or inline on-file comments — and runs headless Claude Code on the PR's own branch to address them, pushing the follow-up commit(s) to the same branch. - Authorization gate: only repo collaborators (write access) + the owner can trigger it; the bot's own comments are ignored. - Idempotent: handled comments are tracked by a hidden marker on the bot's acknowledgements, so the 10-min poll never redoes a comment. - Own clone (~/pelagia-pr-review), config, and lock so it never races the issue watcher. Token needs write:repository + write:issue. Adds the script, an example config, .gitignore entries for the live config/lock, and an automation/README.md section with deploy + cron steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
14 KiB
Bash
277 lines
14 KiB
Bash
#!/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')
|
|
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
|
|
|
|
# --- identity + authorization set ---
|
|
# The bot's own login (so its acknowledgements are never treated as instructions).
|
|
BOT_LOGIN=$(api GET "/user" | jq -r '.login // ""')
|
|
# Collaborators = users with write access. The repo owner is always allowed.
|
|
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(", ")') (bot=$BOT_LOGIN)"
|
|
|
|
# --- 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)')
|
|
prs=$(printf '%s' "$prs" | jq -c ".[:$MAX_PRS]")
|
|
n_prs=$(printf '%s' "$prs" | jq 'length')
|
|
log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' 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
|
|
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')
|
|
|
|
sel='select(.body != null) | select(.body | contains($m))
|
|
| select(.user.login as $u | ($collab | index($u)))
|
|
| select(.user.login != $bot)'
|
|
|
|
conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" "
|
|
[ .[] | $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" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" "
|
|
[ .[] | 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" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" "
|
|
[ .[] | $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
|
|
log " $n new '$MARKER' comment(s) to address"
|
|
|
|
# ---- 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 3100 ONLY:"
|
|
printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)"
|
|
printf '%s\n' " Stop ONLY your own server by port ('fuser -k 3100/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' "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)"
|
|
( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \
|
|
--max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$?
|
|
log " Claude exited with code $rc for PR #$num"
|
|
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\`.
|
|
<!-- $HANDLED_TAG -->"
|
|
continue
|
|
fi
|
|
body="[Claude review-bot] Addressed the following review comment(s) on \`$branch\` ($commits commit(s) pushed):
|
|
|
|
$ack_items
|
|
${note:+
|
|
Notes:
|
|
$note
|
|
}
|
|
<!-- $HANDLED_TAG $keys -->"
|
|
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
|
|
<!-- $HANDLED_TAG $keys -->"
|
|
log " PR #$num: no change, acknowledged (marked handled to avoid re-running)"
|
|
fi
|
|
done
|
|
|
|
log "PR-review watcher run complete."
|