#!/usr/bin/env bash set -euo pipefail usage() { cat < scripts/pr review-checkout-main scripts/pr review-checkout-pr scripts/pr review-guard scripts/pr review-artifacts-init scripts/pr review-validate-artifacts scripts/pr review-tests [ ...] scripts/pr prepare-init scripts/pr prepare-validate-commit scripts/pr prepare-gates scripts/pr prepare-push scripts/pr prepare-run scripts/pr merge-verify scripts/pr merge-run USAGE } require_cmds() { local missing=() local cmd for cmd in git gh jq rg pnpm node; do if ! command -v "$cmd" >/dev/null 2>&1; then missing+=("$cmd") fi done if [ "${#missing[@]}" -gt 0 ]; then echo "Missing required command(s): ${missing[*]}" exit 1 fi } repo_root() { # Resolve canonical root from script location so wrappers work from root or worktree cwd. local script_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" (cd "$script_dir/.." && pwd) } enter_worktree() { local pr="$1" local reset_to_main="${2:-false}" local invoke_cwd invoke_cwd="$PWD" local root root=$(repo_root) if [ "$invoke_cwd" != "$root" ]; then echo "Detected non-root invocation cwd=$invoke_cwd, using canonical root $root" fi cd "$root" gh auth status >/dev/null git fetch origin main local dir=".worktrees/pr-$pr" if [ -d "$dir" ]; then cd "$dir" git fetch origin main if [ "$reset_to_main" = "true" ]; then git checkout -B "temp/pr-$pr" origin/main fi else git worktree add "$dir" -b "temp/pr-$pr" origin/main cd "$dir" fi mkdir -p .local } pr_meta_json() { local pr="$1" gh pr view "$pr" --json number,title,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepository,headRepositoryOwner,url,body,labels,assignees,reviewRequests,files,additions,deletions,statusCheckRollup } write_pr_meta_files() { local json="$1" printf '%s\n' "$json" > .local/pr-meta.json cat > .local/pr-meta.env <"$filtered_log"; then echo "Relevant log lines:" tail -n 120 "$filtered_log" else echo "No focused error markers found; showing last 120 lines:" tail -n 120 "$log_file" fi rm -f "$filtered_log" } run_quiet_logged() { local label="$1" local log_file="$2" shift 2 mkdir -p .local if "$@" >"$log_file" 2>&1; then echo "$label passed" return 0 fi echo "$label failed (log: $log_file)" print_relevant_log_excerpt "$log_file" return 1 } bootstrap_deps_if_needed() { if [ ! -x node_modules/.bin/vitest ]; then run_quiet_logged "pnpm install --frozen-lockfile" ".local/bootstrap-install.log" pnpm install --frozen-lockfile fi } wait_for_pr_head_sha() { local pr="$1" local expected_sha="$2" local max_attempts="${3:-6}" local sleep_seconds="${4:-2}" local attempt for attempt in $(seq 1 "$max_attempts"); do local observed_sha observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) if [ "$observed_sha" = "$expected_sha" ]; then return 0 fi if [ "$attempt" -lt "$max_attempts" ]; then sleep "$sleep_seconds" fi done return 1 } is_author_email_merge_error() { local msg="$1" printf '%s\n' "$msg" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email' } merge_author_email_candidates() { local reviewer="$1" local reviewer_id="$2" local gh_email gh_email=$(gh api user --jq '.email // ""' 2>/dev/null || true) local git_email git_email=$(git config user.email 2>/dev/null || true) printf '%s\n' \ "$gh_email" \ "$git_email" \ "${reviewer_id}+${reviewer}@users.noreply.github.com" \ "${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++' } checkout_prep_branch() { local pr="$1" require_artifact .local/prep-context.env # shellcheck disable=SC1091 source .local/prep-context.env local prep_branch="${PREP_BRANCH:-pr-$pr-prep}" if ! git show-ref --verify --quiet "refs/heads/$prep_branch"; then echo "Expected prep branch $prep_branch not found. Run prepare-init first." exit 1 fi git checkout "$prep_branch" } resolve_head_push_url() { # shellcheck disable=SC1091 source .local/pr-meta.env if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then printf 'https://github.com/%s/%s.git\n' "$PR_HEAD_OWNER" "$PR_HEAD_REPO_NAME" return 0 fi if [ -n "${PR_HEAD_REPO_URL:-}" ] && [ "$PR_HEAD_REPO_URL" != "null" ]; then case "$PR_HEAD_REPO_URL" in *.git) printf '%s\n' "$PR_HEAD_REPO_URL" ;; *) printf '%s.git\n' "$PR_HEAD_REPO_URL" ;; esac return 0 fi return 1 } set_review_mode() { local mode="$1" cat > .local/review-mode.env < .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": "READY FOR /prepare-pr", "findings": [], "tests": { "ran": [], "gaps": [], "result": "pass" }, "docs": "not_applicable", "changelog": "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 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 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") ;; *) echo "Invalid changelog status in .local/review.json: $changelog_status (must be \"required\")" exit 1 ;; esac echo "review artifacts validated" } review_tests() { local pr="$1" shift if [ "$#" -lt 1 ]; then echo "Usage: scripts/pr review-tests [ ...]" 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 list_log=".local/review-tests-list.log" run_quiet_logged "pnpm vitest list" "$list_log" pnpm vitest list "$@" local missing_list=() for target in "$@"; do local base base=$(basename "$target") if ! rg -F -q "$target" "$list_log" && ! rg -F -q "$base" "$list_log"; then missing_list+=("$target") fi done if [ "${#missing_list[@]}" -gt 0 ]; then echo "These requested targets were not selected by vitest list:" printf ' - %s\n' "${missing_list[@]}" exit 1 fi local run_log=".local/review-tests-run.log" run_quiet_logged "pnpm vitest run" "$run_log" pnpm vitest run "$@" 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 < .local/prep-context.env < .local/prep.md < .local/gates.env </dev/null || git remote set-url prhead "$push_url" local remote_sha remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" | awk '{print $1}') if [ -z "$remote_sha" ]; then echo "Remote branch refs/heads/$PR_HEAD not found on prhead" exit 1 fi local pushed_from_sha="$remote_sha" if [ "$remote_sha" = "$prep_head_sha" ]; then echo "Remote branch already at local prep HEAD; skipping push." else if [ "$remote_sha" != "$lease_sha" ]; then echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote." lease_sha="$remote_sha" fi pushed_from_sha="$lease_sha" if ! git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD; then echo "Lease push failed, retrying once with fresh PR head..." lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) pushed_from_sha="$lease_sha" git fetch origin "pull/$pr/head:pr-$pr-latest" --force git rebase "pr-$pr-latest" prep_head_sha=$(git rev-parse HEAD) bootstrap_deps_if_needed run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check if [ "${DOCS_ONLY:-false}" != "true" ]; then run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test fi git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD fi fi if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then local observed_sha observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha" exit 1 fi local pr_head_sha_after pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) git fetch origin main git fetch origin "pull/$pr/head:pr-$pr-verify" --force git merge-base --is-ancestor origin/main "pr-$pr-verify" || { echo "PR branch is behind main after push." exit 1 } git branch -D "pr-$pr-verify" 2>/dev/null || true local contrib="${PR_AUTHOR:-}" if [ -z "$contrib" ]; then contrib=$(gh pr view "$pr" --json author --jq .author.login) fi local contrib_id contrib_id=$(gh api "users/$contrib" --jq .id) local coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com" cat >> .local/prep.md < .local/prep.env </dev/null echo "prepare-push complete" echo "prep_branch=$(git branch --show-current)" echo "prep_head_sha=$prep_head_sha" echo "pr_head_sha=$pr_head_sha_after" echo "artifacts=.local/prep.md .local/prep.env" } prepare_run() { local pr="$1" prepare_init "$pr" prepare_validate_commit "$pr" prepare_gates "$pr" prepare_push "$pr" echo "prepare-run complete for PR #$pr" } merge_verify() { local pr="$1" enter_worktree "$pr" false require_artifact .local/prep.env # shellcheck disable=SC1091 source .local/prep.env local json json=$(pr_meta_json "$pr") local is_draft is_draft=$(printf '%s\n' "$json" | jq -r .isDraft) if [ "$is_draft" = "true" ]; then echo "PR is draft." exit 1 fi local pr_head_sha pr_head_sha=$(printf '%s\n' "$json" | jq -r .headRefOid) if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then echo "PR head changed after prepare (expected $PREP_HEAD_SHA, got $pr_head_sha)." echo "Re-run prepare to refresh prep artifacts and gates: scripts/pr-prepare run $pr" # Best-effort delta summary to show exactly what changed since PREP_HEAD_SHA. git fetch origin "pull/$pr/head" >/dev/null 2>&1 || true if git cat-file -e "${PREP_HEAD_SHA}^{commit}" 2>/dev/null && git cat-file -e "${pr_head_sha}^{commit}" 2>/dev/null; then echo "HEAD delta (expected...current):" git log --oneline --left-right "${PREP_HEAD_SHA}...${pr_head_sha}" | sed 's/^/ /' || true else echo "HEAD delta unavailable locally (could not resolve one of the SHAs)." fi exit 1 fi gh pr checks "$pr" --required --watch --fail-fast >.local/merge-checks-watch.log 2>&1 || true local checks_json local checks_err_file checks_err_file=$(mktemp) checks_json=$(gh pr checks "$pr" --required --json name,bucket,state 2>"$checks_err_file" || true) rm -f "$checks_err_file" if [ -z "$checks_json" ]; then checks_json='[]' fi local required_count required_count=$(printf '%s\n' "$checks_json" | jq 'length') if [ "$required_count" -eq 0 ]; then echo "No required checks configured for this PR." fi printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"' local failed_required failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length') local pending_required pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length') if [ "$failed_required" -gt 0 ]; then echo "Required checks are failing." exit 1 fi if [ "$pending_required" -gt 0 ]; then echo "Required checks are still pending." exit 1 fi git fetch origin main git fetch origin "pull/$pr/head:pr-$pr" --force git merge-base --is-ancestor origin/main "pr-$pr" || { echo "PR branch is behind main." exit 1 } echo "merge-verify passed for PR #$pr" } merge_run() { local pr="$1" enter_worktree "$pr" false local required for required in .local/review.md .local/review.json .local/prep.md .local/prep.env; do require_artifact "$required" done merge_verify "$pr" # shellcheck disable=SC1091 source .local/prep.env local pr_meta_json pr_meta_json=$(gh pr view "$pr" --json number,title,state,isDraft,author) local pr_title pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title) local pr_number pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number) local contrib contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login) local is_draft is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft) if [ "$is_draft" = "true" ]; then echo "PR is draft; stop." exit 1 fi local reviewer reviewer=$(gh api user --jq .login) local reviewer_id reviewer_id=$(gh api user --jq .id) local contrib_coauthor_email="${COAUTHOR_EMAIL:-}" if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then local contrib_id contrib_id=$(gh api "users/$contrib" --jq .id) contrib_coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com" fi local reviewer_email_candidates=() local reviewer_email_candidate while IFS= read -r reviewer_email_candidate; do [ -n "$reviewer_email_candidate" ] || continue reviewer_email_candidates+=("$reviewer_email_candidate") done < <(merge_author_email_candidates "$reviewer" "$reviewer_id") if [ "${#reviewer_email_candidates[@]}" -eq 0 ]; then echo "Unable to resolve a candidate merge author email for reviewer $reviewer" exit 1 fi local reviewer_email="${reviewer_email_candidates[0]}" local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com" cat > .local/merge-body.txt < /prepare-pr -> /merge-pr. Prepared head SHA: $PREP_HEAD_SHA Co-authored-by: $contrib <$contrib_coauthor_email> Co-authored-by: $reviewer <$reviewer_coauthor_email> Reviewed-by: @$reviewer EOF_BODY run_merge_with_email() { local email="$1" local merge_output_file merge_output_file=$(mktemp) if gh pr merge "$pr" \ --squash \ --delete-branch \ --match-head-commit "$PREP_HEAD_SHA" \ --author-email "$email" \ --subject "$pr_title (#$pr_number)" \ --body-file .local/merge-body.txt \ >"$merge_output_file" 2>&1 then rm -f "$merge_output_file" return 0 fi MERGE_ERR_MSG=$(cat "$merge_output_file") print_relevant_log_excerpt "$merge_output_file" rm -f "$merge_output_file" return 1 } local MERGE_ERR_MSG="" local selected_merge_author_email="$reviewer_email" if ! run_merge_with_email "$selected_merge_author_email"; then if is_author_email_merge_error "$MERGE_ERR_MSG" && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then selected_merge_author_email="${reviewer_email_candidates[1]}" echo "Retrying merge once with fallback author email: $selected_merge_author_email" run_merge_with_email "$selected_merge_author_email" || { echo "Merge failed after fallback retry." exit 1 } else echo "Merge failed." exit 1 fi fi local state state=$(gh pr view "$pr" --json state --jq .state) if [ "$state" != "MERGED" ]; then echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..." local i for i in $(seq 1 90); do sleep 10 state=$(gh pr view "$pr" --json state --jq .state) if [ "$state" = "MERGED" ]; then break fi done fi if [ "$state" != "MERGED" ]; then echo "PR state is $state after waiting." exit 1 fi local merge_sha merge_sha=$(gh pr view "$pr" --json mergeCommit --jq '.mergeCommit.oid') if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then echo "Merge commit SHA missing." exit 1 fi local commit_body commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message) printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; } printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; } local ok=0 local comment_output="" local attempt for attempt in 1 2 3; do if comment_output=$(gh pr comment "$pr" -F - 2>&1 </dev/null || true git branch -D "pr-$pr" 2>/dev/null || true git branch -D "pr-$pr-prep" 2>/dev/null || true echo "merge-run complete for PR #$pr" echo "merge_sha=$merge_sha" echo "merge_author_email=$selected_merge_author_email" echo "comment_url=$comment_url" } main() { if [ "$#" -lt 2 ]; then usage exit 2 fi require_cmds local cmd="${1-}" shift || true local pr="${1-}" shift || true if [ -z "$cmd" ] || [ -z "$pr" ]; then usage exit 2 fi case "$cmd" in review-init) review_init "$pr" ;; review-checkout-main) review_checkout_main "$pr" ;; review-checkout-pr) review_checkout_pr "$pr" ;; review-guard) review_guard "$pr" ;; review-artifacts-init) review_artifacts_init "$pr" ;; review-validate-artifacts) review_validate_artifacts "$pr" ;; review-tests) review_tests "$pr" "$@" ;; prepare-init) prepare_init "$pr" ;; prepare-validate-commit) prepare_validate_commit "$pr" ;; prepare-gates) prepare_gates "$pr" ;; prepare-push) prepare_push "$pr" ;; prepare-run) prepare_run "$pr" ;; merge-verify) merge_verify "$pr" ;; merge-run) merge_run "$pr" ;; *) usage exit 2 ;; esac } main "$@"