From 7acd86e3dd80671dc82b90d6bdd5e97948062ed8 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 15:20:55 +0530 Subject: [PATCH] feat(automation): watcher that addresses claude-review: comments on Claude PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + automation/README.md | 67 +++++ automation/claude-pr-review-watcher.sh | 277 ++++++++++++++++++ .../pr-review-watcher.config.example.json | 13 + 4 files changed, 361 insertions(+) create mode 100644 automation/claude-pr-review-watcher.sh create mode 100644 automation/pr-review-watcher.config.example.json diff --git a/.gitignore b/.gitignore index a179b25..82ba000 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ automation/watcher.config.json automation/logs/ automation/.watcher.lock +# Claude PR-review-comment watcher (real token + lock stay local; shares logs/) +automation/pr-review-watcher.config.json +automation/.pr-review-watcher.lock + # OS .DS_Store Thumbs.db diff --git a/automation/README.md b/automation/README.md index 4b4a88e..9c19b0c 100644 --- a/automation/README.md +++ b/automation/README.md @@ -70,6 +70,7 @@ A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the | Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) | | Issue watcher (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash port; runs 24/7 via cron. Config + logs under `~/issue-watcher/` | | Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original. `PelagiaClaudeIssueWatcher` task is **disabled** (pms1 is the sole worker; two pollers would race) | +| PR review-comment watcher | `automation/claude-pr-review-watcher.sh` on pms1 | Addresses `claude-review:` comments on Claude-raised PRs. Own cron entry, own clone (`~/pelagia-pr-review`), own config + lock. See below | | Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) | | Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner | | Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` | @@ -93,6 +94,72 @@ activates automatically once signed in. (An `ANTHROPIC_API_KEY` env var also sat The Windows variant (`.ps1` + `register-watcher-task.ps1`) is the portable fallback; re-enable its task only if pms1 is unavailable, and disable one before enabling the other. +## PR review-comment watcher + +Where the issue watcher turns *issues* into PRs, the **PR review-comment watcher** +([`automation/claude-pr-review-watcher.sh`](claude-pr-review-watcher.sh)) closes the +loop on the other side: it addresses **review comments left on the PRs Claude already +raised**. This is how you iterate on an automated PR without dropping into an +interactive session — leave a comment, Claude pushes a follow-up commit. + +**How to use it (as a reviewer):** on any open Claude-raised PR, leave a comment that +starts with the marker **`claude-review:`** — the text after the marker is the +instruction. It works in three places: + +- the **PR conversation** (a normal PR comment), +- a **review summary** (the overall body of a submitted review), +- an **inline / on-file comment** (Claude is given the file, line, and diff hunk). + +Example inline comment on `App/lib/foo.ts`: + +> `claude-review:` this should null-check `order.vendor` before dereferencing it, and add a test for the null case. + +**What the watcher does each run (every 10 min via cron):** + +1. Lists open PRs Claude raised — head branch starts with `prBranchPrefix` (`claude/`) + or the PR is labelled `claude-pr`. +2. Collects every `claude-review:` comment **from repo collaborators only** (write + access; the repo owner is always included). Comments from anyone else, and the + bot's own comments, are ignored. This is the safety gate — only trusted users can + make Claude push code. +3. Skips comments already handled in a previous run (tracked by a hidden + `` marker the bot stamps on its acknowledgements, + so a 10-minute poll never redoes the same comment). +4. Checks out the **PR's own branch** in `~/pelagia-pr-review`, runs headless Claude + Code with the collected instructions (+ the same `pelagia_test` / port-3100 test + environment the fixer uses), then pushes the new commit(s) to **the same branch** — + updating the open PR in place. +5. Acknowledges: posts a reply listing what it addressed (with the handled marker) and + adds a 🚀 reaction to each handled PR-conversation comment. + +If Claude judges a comment unclear, out of scope, or too risky to do unattended +(migrations, payments, permissions), it makes no commit for it and the watcher posts a +"produced no change — a human may need to take these" reply. The comments are still +marked handled so the poll doesn't loop on them; re-comment with a clearer +`claude-review:` instruction to retry. + +**Deploy on pms1** (mirrors the issue watcher): + +```sh +# 1. Place the script + config alongside the issue watcher +cp automation/claude-pr-review-watcher.sh ~/pr-review-watcher/ +cp automation/pr-review-watcher.config.example.json ~/pr-review-watcher/pr-review-watcher.config.json +# 2. Edit the config: real token (scope write:repository,write:issue), claudeExe = `which claude` +# 3. Add a crontab entry, OFFSET from the issue watcher so the two don't run at the same minute: +# 5,15,25,35,45,55 * * * * PATH=:$PATH ~/pr-review-watcher/claude-pr-review-watcher.sh >> ~/pr-review-watcher/logs/cron.log 2>&1 +``` + +- **Token scope:** needs `write:repository` (push to the PR branch) **plus** + `write:issue` (post comments + reactions) — one scope more than the issue watcher. +- **Own everything:** separate clone (`~/pelagia-pr-review`), config + (`pr-review-watcher.config.json`), and lock (`.pr-review-watcher.lock`) so it never + races the issue watcher. Logs land in the same `logs/` dir + (`pr-review-.log`, per-PR `claude-pr--*.log`). +- Same **auth preflight** as the issue watcher — no-ops until Claude Code is signed in + on pms1 (or `ANTHROPIC_API_KEY` is set). +- A Windows `.ps1` port is not provided yet (pms1 is the sole worker); port it from + `claude-issue-watcher.ps1` only if you need a failover. + ## Test database (for autofix verification) So the fix stage can verify against realistic data without touching production: diff --git a/automation/claude-pr-review-watcher.sh b/automation/claude-pr-review-watcher.sh new file mode 100644 index 0000000..fb31622 --- /dev/null +++ b/automation/claude-pr-review-watcher.sh @@ -0,0 +1,277 @@ +#!/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\`. +" + 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." diff --git a/automation/pr-review-watcher.config.example.json b/automation/pr-review-watcher.config.example.json new file mode 100644 index 0000000..8709972 --- /dev/null +++ b/automation/pr-review-watcher.config.example.json @@ -0,0 +1,13 @@ +{ + "forgejoUrl": "https://git.pelagiamarine.com", + "repo": "shad0w/pelagia-portal", + "token": "", + "workDir": "/home/shad0w/pelagia-pr-review", + "baseBranch": "master", + "prBranchPrefix": "claude/", + "marker": "claude-review:", + "maxPrsPerRun": 1, + "maxCommentsPerPr": 20, + "claudeExe": "/home/shad0w/.nvm/versions/node//bin/claude", + "claudeMaxTurns": 150 +}