openclaw/scripts/pr-lib/review.sh

524 lines
16 KiB
Bash

set_review_mode() {
local mode="$1"
cat > .local/review-mode.env <<EOF_ENV
REVIEW_MODE=$mode
REVIEW_MODE_SET_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF_ENV
}
review_claim() {
local pr="$1"
local root
root=$(repo_root)
cd "$root"
mkdir -p .local
local reviewer=""
local max_attempts=3
local attempt
for attempt in $(seq 1 "$max_attempts"); do
local user_log
user_log=".local/review-claim-user-attempt-$attempt.log"
if reviewer=$(gh api user --jq .login 2>"$user_log"); then
printf "%s\n" "$reviewer" >"$user_log"
break
fi
echo "Claim reviewer lookup failed (attempt $attempt/$max_attempts)."
print_relevant_log_excerpt "$user_log"
if [ "$attempt" -lt "$max_attempts" ]; then
sleep 2
fi
done
if [ -z "$reviewer" ]; then
echo "Failed to resolve reviewer login after $max_attempts attempts."
return 1
fi
for attempt in $(seq 1 "$max_attempts"); do
local claim_log
claim_log=".local/review-claim-assignee-attempt-$attempt.log"
if gh pr edit "$pr" --add-assignee "$reviewer" >"$claim_log" 2>&1; then
echo "review claim succeeded: @$reviewer assigned to PR #$pr"
return 0
fi
echo "Claim assignee update failed (attempt $attempt/$max_attempts)."
print_relevant_log_excerpt "$claim_log"
if [ "$attempt" -lt "$max_attempts" ]; then
sleep 2
fi
done
echo "Failed to assign @$reviewer to PR #$pr after $max_attempts attempts."
return 1
}
review_checkout_main() {
local pr="$1"
enter_worktree "$pr" false
git fetch origin main
git checkout --detach origin/main
set_review_mode main
echo "review mode set to main baseline"
echo "branch=$(git branch --show-current)"
echo "head=$(git rev-parse --short HEAD)"
}
review_checkout_pr() {
local pr="$1"
enter_worktree "$pr" false
git fetch origin "pull/$pr/head:pr-$pr" --force
git checkout --detach "pr-$pr"
set_review_mode pr
echo "review mode set to PR head"
echo "branch=$(git branch --show-current)"
echo "head=$(git rev-parse --short HEAD)"
}
review_guard() {
local pr="$1"
enter_worktree "$pr" false
require_artifact .local/review-mode.env
require_artifact .local/pr-meta.env
# shellcheck disable=SC1091
source .local/review-mode.env
# shellcheck disable=SC1091
source .local/pr-meta.env
local branch
branch=$(git branch --show-current)
local head_sha
head_sha=$(git rev-parse HEAD)
case "${REVIEW_MODE:-}" in
main)
local expected_main_sha
expected_main_sha=$(git rev-parse origin/main)
if [ "$head_sha" != "$expected_main_sha" ]; then
echo "Review guard failed: expected HEAD at origin/main ($expected_main_sha) for main baseline mode, got $head_sha"
exit 1
fi
;;
pr)
if [ -z "${PR_HEAD_SHA:-}" ]; then
echo "Review guard failed: missing PR_HEAD_SHA in .local/pr-meta.env"
exit 1
fi
if [ "$head_sha" != "$PR_HEAD_SHA" ]; then
echo "Review guard failed: expected HEAD at PR_HEAD_SHA ($PR_HEAD_SHA), got $head_sha"
exit 1
fi
;;
*)
echo "Review guard failed: unknown review mode '${REVIEW_MODE:-}'"
exit 1
;;
esac
echo "review guard passed"
echo "mode=$REVIEW_MODE"
echo "branch=$branch"
echo "head=$head_sha"
}
review_artifacts_init() {
local pr="$1"
enter_worktree "$pr" false
require_artifact .local/pr-meta.env
if [ ! -f .local/review.md ]; then
cat > .local/review.md <<'EOF_MD'
A) TL;DR recommendation
B) What changed and what is good?
C) Security findings
D) What is the PR intent? Is this the most optimal implementation?
E) Concerns or questions (actionable)
F) Tests
G) Docs status
H) Changelog
I) Follow ups (optional)
J) Suggested PR comment (optional)
EOF_MD
fi
if [ ! -f .local/review.json ]; then
cat > .local/review.json <<'EOF_JSON'
{
"recommendation": "NEEDS WORK",
"findings": [],
"nitSweep": {
"performed": true,
"status": "none",
"summary": "No optional nits identified."
},
"behavioralSweep": {
"performed": true,
"status": "not_applicable",
"summary": "No runtime branch-level behavior changes require sweep evidence.",
"silentDropRisk": "none",
"branches": []
},
"issueValidation": {
"performed": true,
"source": "pr_body",
"status": "unclear",
"summary": "Review not completed yet."
},
"tests": {
"ran": [],
"gaps": [],
"result": "pass"
},
"docs": "not_applicable",
"changelog": "not_required"
}
EOF_JSON
fi
echo "review artifact templates are ready"
echo "files=.local/review.md .local/review.json"
}
review_validate_artifacts() {
local pr="$1"
enter_worktree "$pr" false
require_artifact .local/review.md
require_artifact .local/review.json
require_artifact .local/pr-meta.env
require_artifact .local/pr-meta.json
review_guard "$pr"
jq . .local/review.json >/dev/null
local section
for section in "A)" "B)" "C)" "D)" "E)" "F)" "G)" "H)" "I)" "J)"; do
awk -v s="$section" 'index($0, s) == 1 { found=1; exit } END { exit(found ? 0 : 1) }' .local/review.md || {
echo "Missing section header in .local/review.md: $section"
exit 1
}
done
local recommendation
recommendation=$(jq -r '.recommendation // ""' .local/review.json)
case "$recommendation" in
"READY FOR /prepare-pr"|"NEEDS WORK"|"NEEDS DISCUSSION"|"NOT USEFUL (CLOSE)")
;;
*)
echo "Invalid recommendation in .local/review.json: $recommendation"
exit 1
;;
esac
local invalid_severity_count
invalid_severity_count=$(jq '[.findings[]? | select((.severity // "") != "BLOCKER" and (.severity // "") != "IMPORTANT" and (.severity // "") != "NIT")] | length' .local/review.json)
if [ "$invalid_severity_count" -gt 0 ]; then
echo "Invalid finding severity in .local/review.json"
exit 1
fi
local invalid_findings_count
invalid_findings_count=$(jq '[.findings[]? | select((.id|type)!="string" or (.title|type)!="string" or (.area|type)!="string" or (.fix|type)!="string")] | length' .local/review.json)
if [ "$invalid_findings_count" -gt 0 ]; then
echo "Invalid finding shape in .local/review.json (id/title/area/fix must be strings)"
exit 1
fi
local nit_findings_count
nit_findings_count=$(jq '[.findings[]? | select((.severity // "") == "NIT")] | length' .local/review.json)
local nit_sweep_performed
nit_sweep_performed=$(jq -r '.nitSweep.performed // empty' .local/review.json)
if [ "$nit_sweep_performed" != "true" ]; then
echo "Invalid nit sweep in .local/review.json: nitSweep.performed must be true"
exit 1
fi
local nit_sweep_status
nit_sweep_status=$(jq -r '.nitSweep.status // ""' .local/review.json)
case "$nit_sweep_status" in
"none")
if [ "$nit_findings_count" -gt 0 ]; then
echo "Invalid nit sweep in .local/review.json: nitSweep.status is none but NIT findings exist"
exit 1
fi
;;
"has_nits")
if [ "$nit_findings_count" -lt 1 ]; then
echo "Invalid nit sweep in .local/review.json: nitSweep.status is has_nits but no NIT findings exist"
exit 1
fi
;;
*)
echo "Invalid nit sweep status in .local/review.json: $nit_sweep_status"
exit 1
;;
esac
local invalid_nit_summary_count
invalid_nit_summary_count=$(jq '[.nitSweep.summary | select((type != "string") or (gsub("^\\s+|\\s+$";"") | length == 0))] | length' .local/review.json)
if [ "$invalid_nit_summary_count" -gt 0 ]; then
echo "Invalid nit sweep summary in .local/review.json: nitSweep.summary must be a non-empty string"
exit 1
fi
local issue_validation_performed
issue_validation_performed=$(jq -r '.issueValidation.performed // empty' .local/review.json)
if [ "$issue_validation_performed" != "true" ]; then
echo "Invalid issue validation in .local/review.json: issueValidation.performed must be true"
exit 1
fi
local issue_validation_source
issue_validation_source=$(jq -r '.issueValidation.source // ""' .local/review.json)
case "$issue_validation_source" in
"linked_issue"|"pr_body"|"both")
;;
*)
echo "Invalid issue validation source in .local/review.json: $issue_validation_source"
exit 1
;;
esac
local issue_validation_status
issue_validation_status=$(jq -r '.issueValidation.status // ""' .local/review.json)
case "$issue_validation_status" in
"valid"|"unclear"|"invalid"|"already_fixed_on_main")
;;
*)
echo "Invalid issue validation status in .local/review.json: $issue_validation_status"
exit 1
;;
esac
local invalid_issue_summary_count
invalid_issue_summary_count=$(jq '[.issueValidation.summary | select((type != "string") or (gsub("^\\s+|\\s+$";"") | length == 0))] | length' .local/review.json)
if [ "$invalid_issue_summary_count" -gt 0 ]; then
echo "Invalid issue validation summary in .local/review.json: issueValidation.summary must be a non-empty string"
exit 1
fi
local runtime_file_count
runtime_file_count=$(jq '[.files[]? | (.path // "") | select(test("^(src|extensions|apps)/")) | select(test("(^|/)__tests__/|\\.test\\.|\\.spec\\.") | not) | select(test("\\.(md|mdx)$") | not)] | length' .local/pr-meta.json)
local runtime_review_required="false"
if [ "$runtime_file_count" -gt 0 ]; then
runtime_review_required="true"
fi
local behavioral_sweep_performed
behavioral_sweep_performed=$(jq -r '.behavioralSweep.performed // empty' .local/review.json)
if [ "$behavioral_sweep_performed" != "true" ]; then
echo "Invalid behavioral sweep in .local/review.json: behavioralSweep.performed must be true"
exit 1
fi
local behavioral_sweep_status
behavioral_sweep_status=$(jq -r '.behavioralSweep.status // ""' .local/review.json)
case "$behavioral_sweep_status" in
"pass"|"needs_work"|"not_applicable")
;;
*)
echo "Invalid behavioral sweep status in .local/review.json: $behavioral_sweep_status"
exit 1
;;
esac
local behavioral_sweep_risk
behavioral_sweep_risk=$(jq -r '.behavioralSweep.silentDropRisk // ""' .local/review.json)
case "$behavioral_sweep_risk" in
"none"|"present"|"unknown")
;;
*)
echo "Invalid behavioral sweep risk in .local/review.json: $behavioral_sweep_risk"
exit 1
;;
esac
local invalid_behavioral_summary_count
invalid_behavioral_summary_count=$(jq '[.behavioralSweep.summary | select((type != "string") or (gsub("^\\s+|\\s+$";"") | length == 0))] | length' .local/review.json)
if [ "$invalid_behavioral_summary_count" -gt 0 ]; then
echo "Invalid behavioral sweep summary in .local/review.json: behavioralSweep.summary must be a non-empty string"
exit 1
fi
local behavioral_branches_is_array
behavioral_branches_is_array=$(jq -r 'if (.behavioralSweep.branches | type) == "array" then "true" else "false" end' .local/review.json)
if [ "$behavioral_branches_is_array" != "true" ]; then
echo "Invalid behavioral sweep in .local/review.json: behavioralSweep.branches must be an array"
exit 1
fi
local invalid_behavioral_branch_count
invalid_behavioral_branch_count=$(jq '[.behavioralSweep.branches[]? | select((.path|type)!="string" or (.decision|type)!="string" or (.outcome|type)!="string")] | length' .local/review.json)
if [ "$invalid_behavioral_branch_count" -gt 0 ]; then
echo "Invalid behavioral sweep branch entry in .local/review.json: each branch needs string path/decision/outcome"
exit 1
fi
local behavioral_branch_count
behavioral_branch_count=$(jq '[.behavioralSweep.branches[]?] | length' .local/review.json)
if [ "$runtime_review_required" = "true" ] && [ "$behavioral_sweep_status" = "not_applicable" ]; then
echo "Invalid behavioral sweep in .local/review.json: runtime file changes require behavioralSweep.status=pass|needs_work"
exit 1
fi
if [ "$runtime_review_required" = "true" ] && [ "$behavioral_branch_count" -lt 1 ]; then
echo "Invalid behavioral sweep in .local/review.json: runtime file changes require at least one branch entry"
exit 1
fi
if [ "$behavioral_sweep_status" = "not_applicable" ] && [ "$behavioral_branch_count" -gt 0 ]; then
echo "Invalid behavioral sweep in .local/review.json: not_applicable cannot include branch entries"
exit 1
fi
if [ "$behavioral_sweep_status" = "pass" ] && [ "$behavioral_sweep_risk" != "none" ]; then
echo "Invalid behavioral sweep in .local/review.json: status=pass requires silentDropRisk=none"
exit 1
fi
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$issue_validation_status" != "valid" ]; then
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr requires issueValidation.status=valid"
exit 1
fi
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$behavioral_sweep_status" = "needs_work" ]; then
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr requires behavioralSweep.status!=needs_work"
exit 1
fi
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$runtime_review_required" = "true" ] && [ "$behavioral_sweep_status" != "pass" ]; then
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr on runtime changes requires behavioralSweep.status=pass"
exit 1
fi
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$behavioral_sweep_risk" = "present" ]; then
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr is not allowed when behavioralSweep.silentDropRisk=present"
exit 1
fi
local docs_status
docs_status=$(jq -r '.docs // ""' .local/review.json)
case "$docs_status" in
"up_to_date"|"missing"|"not_applicable")
;;
*)
echo "Invalid docs status in .local/review.json: $docs_status"
exit 1
;;
esac
local changelog_status
changelog_status=$(jq -r '.changelog // ""' .local/review.json)
case "$changelog_status" in
"required"|"not_required")
;;
*)
echo "Invalid changelog status in .local/review.json: $changelog_status (must be \"required\" or \"not_required\")"
exit 1
;;
esac
echo "review artifacts validated"
print_review_stdout_summary
}
review_tests() {
local pr="$1"
shift
if [ "$#" -lt 1 ]; then
echo "Usage: scripts/pr review-tests <PR> <test-file> [<test-file> ...]"
exit 2
fi
enter_worktree "$pr" false
review_guard "$pr"
local target
for target in "$@"; do
if [ ! -f "$target" ]; then
echo "Missing test target file: $target"
exit 1
fi
done
bootstrap_deps_if_needed
local run_log=".local/review-tests-run.log"
run_quiet_logged "pnpm test" "$run_log" pnpm test -- "$@"
local missing_run=()
for target in "$@"; do
local base
base=$(basename "$target")
if ! rg -F -q "$target" "$run_log" && ! rg -F -q "$base" "$run_log"; then
missing_run+=("$target")
fi
done
if [ "${#missing_run[@]}" -gt 0 ]; then
echo "These requested targets were not observed in vitest run output:"
printf ' - %s\n' "${missing_run[@]}"
exit 1
fi
{
echo "REVIEW_TESTS_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "REVIEW_TEST_TARGET_COUNT=$#"
} > .local/review-tests.env
echo "review tests passed and were observed in output"
}
review_init() {
local pr="$1"
enter_worktree "$pr" true
local json
json=$(pr_meta_json "$pr")
write_pr_meta_files "$json"
git fetch origin "pull/$pr/head:pr-$pr" --force
local mb
mb=$(git merge-base origin/main "pr-$pr")
cat > .local/review-context.env <<EOF_ENV
PR_NUMBER=$pr
MERGE_BASE=$mb
REVIEW_STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF_ENV
set_review_mode main
printf '%s\n' "$json" | jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headSha:.headRefOid,headRepo:.headRepository.nameWithOwner,additions,deletions,files:(.files|length)}'
echo "worktree=$PWD"
echo "merge_base=$mb"
echo "branch=$(git branch --show-current)"
echo "wrote=.local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env"
cat <<EOF_GUIDE
Review guidance:
- Inspect main baseline: scripts/pr review-checkout-main $pr
- Inspect PR head: scripts/pr review-checkout-pr $pr
- Guard before writeout: scripts/pr review-guard $pr
EOF_GUIDE
}