2026-03-12 12:44:15 +00:00
#!/usr/bin/env bash
2026-03-20 22:59:02 +00:00
# shellcheck disable=SC2015,SC2016
# review-pr.sh — Thin orchestrator for AI PR review (formula: formulas/review-pr.toml)
2026-03-12 12:44:15 +00:00
# Usage: ./review-pr.sh <pr-number> [--force]
set -euo pipefail
source " $( dirname " $0 " ) /../lib/env.sh "
2026-03-18 02:05:54 +00:00
source " $( dirname " $0 " ) /../lib/ci-helpers.sh "
2026-03-20 22:59:02 +00:00
source " $( dirname " $0 " ) /../lib/agent-session.sh "
2026-03-17 19:35:29 +00:00
git -C " $FACTORY_ROOT " pull --ff-only origin main 2>/dev/null || true
2026-03-12 12:44:15 +00:00
PR_NUMBER = " ${ 1 : ?Usage : review-pr.sh <pr-number> [--force] } "
FORCE = " ${ 2 :- } "
2026-03-20 22:59:02 +00:00
API = " ${ CODEBERG_API } "
LOGFILE = " ${ FACTORY_ROOT } /review/review.log "
SESSION = " review- ${ PROJECT_NAME } - ${ PR_NUMBER } "
PHASE_FILE = " /tmp/review-session- ${ PROJECT_NAME } - ${ PR_NUMBER } .phase "
OUTPUT_FILE = " /tmp/ ${ PROJECT_NAME } -review-output- ${ PR_NUMBER } .json "
WORKTREE = " /tmp/ ${ PROJECT_NAME } -review- ${ PR_NUMBER } "
2026-03-14 13:49:09 +01:00
LOCKFILE = " /tmp/ ${ PROJECT_NAME } -review.lock "
STATUSFILE = " /tmp/ ${ PROJECT_NAME } -review-status "
2026-03-12 12:44:15 +00:00
MAX_DIFF = 25000
2026-03-20 22:59:02 +00:00
REVIEW_TMPDIR = $( mktemp -d)
log( ) { printf '[%s] PR#%s %s\n' " $( date -u '+%Y-%m-%d %H:%M:%S UTC' ) " " $PR_NUMBER " " $* " >> " $LOGFILE " ; }
status( ) { printf '[%s] PR #%s: %s\n' " $( date -u '+%Y-%m-%d %H:%M:%S UTC' ) " " $PR_NUMBER " " $* " > " $STATUSFILE " ; log " $* " ; }
cleanup( ) { rm -rf " $REVIEW_TMPDIR " " $LOCKFILE " " $STATUSFILE " ; }
2026-03-12 12:44:15 +00:00
trap cleanup EXIT
if [ -f " $LOGFILE " ] && [ " $( stat -c%s " $LOGFILE " 2>/dev/null || echo 0) " -gt 102400 ] ; then
mv " $LOGFILE " " $LOGFILE .old "
fi
2026-03-20 22:59:02 +00:00
AVAIL = $( awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
[ " $AVAIL " -lt 1500 ] && { log " SKIP: ${ AVAIL } MB available " ; exit 0; }
2026-03-12 12:44:15 +00:00
if [ -f " $LOCKFILE " ] ; then
2026-03-20 22:59:02 +00:00
LPID = $( cat " $LOCKFILE " 2>/dev/null || true )
[ -n " $LPID " ] && kill -0 " $LPID " 2>/dev/null && { log "SKIP: locked" ; exit 0; }
2026-03-12 12:44:15 +00:00
rm -f " $LOCKFILE "
fi
echo $$ > " $LOCKFILE "
status "fetching metadata"
2026-03-20 22:59:02 +00:00
PR_JSON = $( curl -sf -H " Authorization: token ${ CODEBERG_TOKEN } " " ${ API } /pulls/ ${ PR_NUMBER } " )
PR_TITLE = $( printf '%s' " $PR_JSON " | jq -r '.title' )
PR_BODY = $( printf '%s' " $PR_JSON " | jq -r '.body // ""' )
PR_HEAD = $( printf '%s' " $PR_JSON " | jq -r '.head.ref' )
PR_BASE = $( printf '%s' " $PR_JSON " | jq -r '.base.ref' )
PR_SHA = $( printf '%s' " $PR_JSON " | jq -r '.head.sha' )
PR_STATE = $( printf '%s' " $PR_JSON " | jq -r '.state' )
2026-03-12 12:44:15 +00:00
log " ${ PR_TITLE } ( ${ PR_HEAD } → ${ PR_BASE } ${ PR_SHA : 0 : 7 } ) "
if [ " $PR_STATE " != "open" ] ; then
2026-03-20 22:59:02 +00:00
log " SKIP: state= ${ PR_STATE } " ; agent_kill_session " $SESSION "
cd " ${ PROJECT_REPO_ROOT } " ; git worktree remove " $WORKTREE " --force 2>/dev/null || true
rm -rf " $WORKTREE " " $PHASE_FILE " " $OUTPUT_FILE " 2>/dev/null || true; exit 0
2026-03-12 12:44:15 +00:00
fi
CI_STATE = $( curl -sf -H " Authorization: token ${ CODEBERG_TOKEN } " \
2026-03-20 22:59:02 +00:00
" ${ API } /commits/ ${ PR_SHA } /status " | jq -r '.state // "unknown"' )
CI_NOTE = "" ; if ! ci_passed " $CI_STATE " ; then
ci_required_for_pr " $PR_NUMBER " && { log " SKIP: CI= ${ CI_STATE } " ; exit 0; }
CI_NOTE = " (not required — non-code PR)" ; fi
2026-03-18 08:13:43 +00:00
ALL_COMMENTS = $( codeberg_api_all " /issues/ ${ PR_NUMBER } /comments " )
2026-03-20 22:59:02 +00:00
HAS_CMT = $( printf '%s' " $ALL_COMMENTS " | jq --arg s " $PR_SHA " \
'[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length' )
[ " ${ HAS_CMT :- 0 } " -gt 0 ] && [ " $FORCE " != "--force" ] && { log " SKIP: reviewed ${ PR_SHA : 0 : 7 } " ; exit 0; }
HAS_FML = $( codeberg_api_all " /pulls/ ${ PR_NUMBER } /reviews " | jq --arg s " $PR_SHA " \
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length' )
[ " ${ HAS_FML :- 0 } " -gt 0 ] && [ " $FORCE " != "--force" ] && { log "SKIP: formal review" ; exit 0; }
PREV_CONTEXT = "" IS_RE_REVIEW = false PREV_SHA = ""
PREV_REV = $( printf '%s' " $ALL_COMMENTS " | jq -r --arg s " $PR_SHA " \
'[.[]|select(.body|contains("<!-- reviewed:"))|select(.body|contains($s)|not)]|last // empty' )
if [ -n " $PREV_REV " ] && [ " $PREV_REV " != "null" ] ; then
PREV_BODY = $( printf '%s' " $PREV_REV " | jq -r '.body' )
PREV_SHA = $( printf '%s' " $PREV_BODY " | grep -oP '<!-- reviewed: \K[a-f0-9]+' | head -1)
cd " ${ PROJECT_REPO_ROOT } " ; git fetch origin " $PR_HEAD " 2>/dev/null || true
INCR = $( git diff " ${ PREV_SHA } .. ${ PR_SHA } " 2>/dev/null | head -c " $MAX_DIFF " ) || true
if [ -n " $INCR " ] ; then
IS_RE_REVIEW = true; log " re-review: previous at ${ PREV_SHA : 0 : 7 } "
DEV_R = $( printf '%s' " $ALL_COMMENTS " | jq -r \
'[.[]|select(.body|contains("<!-- dev-response:"))]|last // empty' )
DEV_SEC = "" ; [ -n " $DEV_R " ] && [ " $DEV_R " != "null" ] && \
DEV_SEC = $( printf '\n### Developer Response\n%s' " $( printf '%s' " $DEV_R " | jq -r '.body' ) " ) || true
PREV_CONTEXT = $( printf '\n## This is a RE-REVIEW\nPrevious review at %s requested changes.\n### Previous Review\n%s%s\n### Incremental Diff (%s..%s)\n```diff\n%s\n```' \
" ${ PREV_SHA : 0 : 7 } " " $PREV_BODY " " $DEV_SEC " " ${ PREV_SHA : 0 : 7 } " " ${ PR_SHA : 0 : 7 } " " $INCR " )
2026-03-12 12:44:15 +00:00
fi
fi
status "fetching diff"
curl -s -H " Authorization: token ${ CODEBERG_TOKEN } " \
2026-03-20 22:59:02 +00:00
" ${ API } /pulls/ ${ PR_NUMBER } .diff " > " ${ REVIEW_TMPDIR } /full.diff "
FSIZE = $( stat -c%s " ${ REVIEW_TMPDIR } /full.diff " 2>/dev/null || echo 0)
DIFF = $( head -c " $MAX_DIFF " " ${ REVIEW_TMPDIR } /full.diff " )
FILES = $( grep -E '^\+\+\+ b/' " ${ REVIEW_TMPDIR } /full.diff " | sed 's|^+++ b/||' | grep -v '/dev/null' | sort -u || true )
DNOTE = "" ; [ " $FSIZE " -gt " $MAX_DIFF " ] && DNOTE = " (truncated from ${ FSIZE } bytes) "
cd " ${ PROJECT_REPO_ROOT } " ; git fetch origin " $PR_HEAD " 2>/dev/null || true
if [ -d " $WORKTREE " ] ; then
cd " $WORKTREE " ; git checkout --detach " $PR_SHA " 2>/dev/null || {
cd " ${ PROJECT_REPO_ROOT } " ; git worktree remove " $WORKTREE " --force 2>/dev/null || true
rm -rf " $WORKTREE " ; git worktree add " $WORKTREE " " $PR_SHA " --detach 2>/dev/null; }
else git worktree add " $WORKTREE " " $PR_SHA " --detach 2>/dev/null; fi
status "preparing review session"
FORMULA = $( cat " ${ FACTORY_ROOT } /formulas/review-pr.toml " )
2026-03-12 12:44:15 +00:00
{
2026-03-20 22:59:02 +00:00
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\nYou MUST write PHASE:done to ' \' '%s' \' ' when finished.\n\n' \
" ${ CODEBERG_REPO } " " ${ PR_NUMBER } " " ${ PHASE_FILE } "
printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \
" $PR_TITLE " " $PR_HEAD " " $PR_BASE " " $PR_SHA " " $CI_STATE " " $CI_NOTE " " $IS_RE_REVIEW "
printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \
" $PR_BODY " " $FILES " " $DNOTE " " $DIFF "
[ -n " $PREV_CONTEXT " ] && printf '%s\n' " $PREV_CONTEXT "
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nPHASE_FILE=%s\nCODEBERG_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
" $FORMULA " " $OUTPUT_FILE " " $PHASE_FILE " " $API " " $PR_NUMBER " " $FACTORY_ROOT "
printf 'NEVER echo the actual token — always reference $CODEBERG_TOKEN or $REVIEW_BOT_TOKEN.\n'
} > " ${ REVIEW_TMPDIR } /prompt.md "
PROMPT = $( cat " ${ REVIEW_TMPDIR } /prompt.md " )
rm -f " $OUTPUT_FILE " " $PHASE_FILE " ; agent_kill_session " $SESSION "
export CLAUDE_MODEL = "sonnet"
create_agent_session " $SESSION " " $WORKTREE " " $PHASE_FILE " || { log "ERROR: session failed" ; exit 1; }
agent_inject_into_session " $SESSION " " $PROMPT "
log " prompt injected ( ${# PROMPT } bytes, re-review: ${ IS_RE_REVIEW } ) "
status "waiting for review"
_REVIEW_CRASH = 0
review_cb( ) {
log " phase: $1 "
case " $1 " in
PHASE:crashed)
[ " $_REVIEW_CRASH " -gt 0 ] && return 0; _REVIEW_CRASH = $(( _REVIEW_CRASH + 1 ))
create_agent_session " ${ _MONITOR_SESSION } " " $WORKTREE " " $PHASE_FILE " 2>/dev/null && \
agent_inject_into_session " ${ _MONITOR_SESSION } " " $PROMPT " ; ;
2026-03-21 19:39:04 +00:00
PHASE:done| PHASE:failed| PHASE:escalate) agent_kill_session " ${ _MONITOR_SESSION } " ; ;
2026-03-20 22:59:02 +00:00
esac
}
monitor_phase_loop " $PHASE_FILE " 600 "review_cb" " $SESSION "
2026-03-17 23:56:04 +00:00
2026-03-12 12:44:15 +00:00
REVIEW_JSON = ""
2026-03-20 22:59:02 +00:00
if [ -f " $OUTPUT_FILE " ] ; then
RAW = $( cat " $OUTPUT_FILE " )
if printf '%s' " $RAW " | jq -e '.verdict' >/dev/null 2>& 1; then REVIEW_JSON = " $RAW "
2026-03-12 12:44:15 +00:00
else
2026-03-20 22:59:02 +00:00
EXT = $( printf '%s' " $RAW " | sed -n '/^```json/,/^```$/p' | sed '1d;$d' )
[ -z " $EXT " ] && EXT = $( printf '%s' " $RAW " | sed -n '/^{/,/^}/p' )
[ -n " ${ EXT :- } " ] && printf '%s' " $EXT " | jq -e '.verdict' >/dev/null 2>& 1 && REVIEW_JSON = " $EXT "
2026-03-12 12:44:15 +00:00
fi
2026-03-20 22:59:02 +00:00
fi
2026-03-12 12:44:15 +00:00
if [ -z " $REVIEW_JSON " ] ; then
2026-03-20 22:59:02 +00:00
log "ERROR: no valid review output"
jq -n --arg b " ## AI Review — Error\n<!-- review-error: ${ PR_SHA } -->\nReview failed.\n---\n* ${ PR_SHA : 0 : 7 } * " \
'{body: $b}' | curl -sf -o /dev/null -X POST -H " Authorization: token ${ CODEBERG_TOKEN } " \
-H "Content-Type: application/json" " ${ API } /issues/ ${ PR_NUMBER } /comments " -d @- || true
matrix_send "review" " PR # ${ PR_NUMBER } review failed " 2>/dev/null || true; exit 1
2026-03-12 12:44:15 +00:00
fi
2026-03-19 08:34:45 +00:00
VERDICT = $( printf '%s' " $REVIEW_JSON " | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_' )
2026-03-20 22:59:02 +00:00
REASON = $( printf '%s' " $REVIEW_JSON " | jq -r '.verdict_reason // ""' )
REVIEW_MD = $( printf '%s' " $REVIEW_JSON " | jq -r '.review_markdown // ""' )
log " verdict: ${ VERDICT } "
2026-03-12 12:44:15 +00:00
2026-03-20 22:59:02 +00:00
status "posting review"
RTYPE = "Review"
2026-03-12 12:44:15 +00:00
if [ " $IS_RE_REVIEW " = true ] ; then
2026-03-20 22:59:02 +00:00
RTYPE = " Re-review (round $(( $( printf '%s' " $ALL_COMMENTS " | \
jq '[.[]|select(.body|contains("<!-- reviewed:"))]|length' ) + 1) ) ) "
fi
PREV_REF = "" ; [ " $IS_RE_REVIEW " = true ] && PREV_REF = $( printf ' | Previous: `%s`' " ${ PREV_SHA : 0 : 7 } " ) || true
COMMENT_BODY = $( printf '## AI %s\n<!-- reviewed: %s -->\n\n%s\n\n### Verdict\n**%s** — %s\n\n---\n*Reviewed at `%s`%s | [AGENTS.md](AGENTS.md)*' \
" $RTYPE " " $PR_SHA " " $REVIEW_MD " " $VERDICT " " $REASON " " ${ PR_SHA : 0 : 7 } " " $PREV_REF " )
printf '%s' " $COMMENT_BODY " > " ${ REVIEW_TMPDIR } /body.txt "
jq -Rs '{body: .}' < " ${ REVIEW_TMPDIR } /body.txt " > " ${ REVIEW_TMPDIR } /comment.json "
POST_RC = $( curl -s -o /dev/null -w "%{http_code}" -X POST \
-H " Authorization: token ${ REVIEW_BOT_TOKEN } " -H "Content-Type: application/json" \
" ${ API } /issues/ ${ PR_NUMBER } /comments " --data-binary @" ${ REVIEW_TMPDIR } /comment.json " )
[ " $POST_RC " != "201" ] && { log " ERROR: comment HTTP ${ POST_RC } " ; exit 1; }
log "posted review comment"
REVENT = "COMMENT"
case " $VERDICT " in APPROVE) REVENT = "APPROVED" ; ; REQUEST_CHANGES| DISCUSS) REVENT = "REQUEST_CHANGES" ; ; esac
if [ " $REVENT " = "APPROVED" ] ; then
BLOGIN = $( curl -sf -H " Authorization: token ${ REVIEW_BOT_TOKEN } " \
" ${ API %%/repos* } /user " 2>/dev/null | jq -r '.login // empty' || true )
[ -n " $BLOGIN " ] && codeberg_api_all " /pulls/ ${ PR_NUMBER } /reviews " " $REVIEW_BOT_TOKEN " 2>/dev/null | \
jq -r --arg l " $BLOGIN " '.[]|select(.state=="REQUEST_CHANGES")|select(.user.login==$l)|.id' | \
while IFS = read -r rid; do
curl -sf -o /dev/null -X POST -H " Authorization: token ${ REVIEW_BOT_TOKEN } " \
-H "Content-Type: application/json" " ${ API } /pulls/ ${ PR_NUMBER } /reviews/ ${ rid } /dismissals " \
-d '{"message":"Superseded by approval"}' || true; log " dismissed review ${ rid } "
done || true
fi
jq -n --arg b " AI ${ RTYPE } : ** ${ VERDICT } ** — ${ REASON } " --arg e " $REVENT " --arg s " $PR_SHA " \
'{body: $b, event: $e, commit_id: $s}' > " ${ REVIEW_TMPDIR } /formal.json "
curl -s -o /dev/null -X POST -H " Authorization: token ${ REVIEW_BOT_TOKEN } " \
-H "Content-Type: application/json" " ${ API } /pulls/ ${ PR_NUMBER } /reviews " \
--data-binary @" ${ REVIEW_TMPDIR } /formal.json " >/dev/null 2>& 1 || true
log " formal ${ REVENT } submitted "
matrix_send "review" " PR # ${ PR_NUMBER } ${ RTYPE } : ${ VERDICT } — ${ PR_TITLE } " "" " $PR_NUMBER " >/dev/null 2>& 1 || true
2026-03-19 20:09:22 +00:00
case " $VERDICT " in
2026-03-20 22:59:02 +00:00
REQUEST_CHANGES| DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' " $PR_SHA " > " $PHASE_FILE " ; ;
*) rm -f " $PHASE_FILE " " $OUTPUT_FILE " ; cd " ${ PROJECT_REPO_ROOT } "
git worktree remove " $WORKTREE " --force 2>/dev/null || true
rm -rf " $WORKTREE " 2>/dev/null || true ; ;
2026-03-19 20:09:22 +00:00
esac
2026-03-20 22:59:02 +00:00
log " DONE: ${ VERDICT } (re-review: ${ IS_RE_REVIEW } ) "