#!/usr/bin/env bash set -euo pipefail # If invoked from a linked worktree copy of this script, re-exec the canonical # script from the repository root so behavior stays consistent across worktrees. script_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" script_parent_dir="$(dirname "$script_self")" if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then canonical_repo_root="$(dirname "$common_git_dir")" canonical_self="$canonical_repo_root/scripts/$(basename "${BASH_SOURCE[0]}")" if [ "$script_self" != "$canonical_self" ] && [ -x "$canonical_self" ]; then exec "$canonical_self" "$@" fi fi usage() { cat < scripts/pr review-checkout-main scripts/pr review-checkout-pr scripts/pr review-claim 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-sync-head 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 repository root from git common-dir so wrappers work # the same from main checkout or any linked worktree. local script_dir local common_git_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if common_git_dir=$(git -C "$script_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then (cd "$(dirname "$common_git_dir")" && pwd) return fi # Fallback for environments where git common-dir is unavailable. (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 'git@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 } # Push to a fork PR branch via GitHub GraphQL createCommitOnBranch. # This uses the same permission model as the GitHub web editor, bypassing # the git-protocol 403 that occurs even when maintainer_can_modify is true. # Usage: graphql_push_to_fork # Pushes the diff between expected_head_oid and local HEAD as file additions/deletions. # File bytes are read from git objects (not the working tree) to avoid # symlink/special-file dereference risks from untrusted fork content. graphql_push_to_fork() { local repo_nwo="$1" # e.g. Oncomatic/openclaw local branch="$2" # e.g. fix/memory-flush-not-executing local expected_oid="$3" local max_blob_bytes=$((5 * 1024 * 1024)) # Build file changes JSON from the diff between expected_oid and HEAD. local additions="[]" local deletions="[]" # Collect added/modified files local added_files added_files=$(git diff --no-renames --name-only --diff-filter=AM "$expected_oid" HEAD) if [ -n "$added_files" ]; then additions="[" local first=true while IFS= read -r fpath; do [ -n "$fpath" ] || continue local tree_entry tree_entry=$(git ls-tree HEAD -- "$fpath") if [ -z "$tree_entry" ]; then echo "GraphQL push could not resolve path in HEAD tree: $fpath" >&2 return 1 fi local file_mode file_mode=$(printf '%s\n' "$tree_entry" | awk '{print $1}') local file_type file_type=$(printf '%s\n' "$tree_entry" | awk '{print $2}') local file_oid file_oid=$(printf '%s\n' "$tree_entry" | awk '{print $3}') if [ "$file_type" != "blob" ] || [ "$file_mode" = "160000" ]; then echo "GraphQL push only supports blob files; refusing $fpath (mode=$file_mode type=$file_type)" >&2 return 1 fi local blob_size blob_size=$(git cat-file -s "$file_oid") if [ "$blob_size" -gt "$max_blob_bytes" ]; then echo "GraphQL push refused large file $fpath (${blob_size} bytes > ${max_blob_bytes})" >&2 return 1 fi local b64 b64=$(git cat-file -p "$file_oid" | base64 | tr -d '\n') if [ "$first" = true ]; then first=false; else additions+=","; fi additions+="{\"path\":$(printf '%s' "$fpath" | jq -Rs .),\"contents\":$(printf '%s' "$b64" | jq -Rs .)}" done <<< "$added_files" additions+="]" fi # Collect deleted files local deleted_files deleted_files=$(git diff --no-renames --name-only --diff-filter=D "$expected_oid" HEAD) if [ -n "$deleted_files" ]; then deletions="[" local first=true while IFS= read -r fpath; do [ -n "$fpath" ] || continue if [ "$first" = true ]; then first=false; else deletions+=","; fi deletions+="{\"path\":$(printf '%s' "$fpath" | jq -Rs .)}" done <<< "$deleted_files" deletions+="]" fi local commit_headline commit_headline=$(git log -1 --format=%s HEAD) local query query=$(cat <<'GRAPHQL' mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } } GRAPHQL ) local variables variables=$(jq -n \ --arg nwo "$repo_nwo" \ --arg branch "$branch" \ --arg oid "$expected_oid" \ --arg headline "$commit_headline" \ --argjson additions "$additions" \ --argjson deletions "$deletions" \ '{input: { branch: { repositoryNameWithOwner: $nwo, branchName: $branch }, message: { headline: $headline }, fileChanges: { additions: $additions, deletions: $deletions }, expectedHeadOid: $oid }}') local result result=$(gh api graphql -f query="$query" --input - <<< "$variables" 2>&1) || { echo "GraphQL push failed: $result" >&2 return 1 } local new_oid new_oid=$(printf '%s' "$result" | jq -r '.data.createCommitOnBranch.commit.oid // empty') if [ -z "$new_oid" ]; then echo "GraphQL push returned no commit OID: $result" >&2 return 1 fi echo "GraphQL push succeeded: $new_oid" >&2 printf '%s\n' "$new_oid" } # Resolve HTTPS fallback URL for prhead push (used if SSH fails). resolve_head_push_url_https() { # 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 <"$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": "READY FOR /prepare-pr", "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": "valid", "summary": "PR description clearly states a valid problem." }, "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 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") ;; *) 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 < "$diff_file" if ! awk -v pr_pattern="$pr_pattern" ' BEGIN { line_no = 0 file_line_count = 0 issue_count = 0 } FNR == NR { if ($0 ~ /^@@ /) { if (match($0, /\+[0-9]+/)) { line_no = substr($0, RSTART + 1, RLENGTH - 1) + 0 } else { line_no = 0 } next } if ($0 ~ /^\+\+\+/) { next } if ($0 ~ /^\+/) { if (line_no > 0) { added[line_no] = 1 added_text = substr($0, 2) if (added_text ~ pr_pattern) { pr_added_lines[++pr_added_count] = line_no pr_added_text[line_no] = added_text } line_no++ } next } if ($0 ~ /^-/) { next } if (line_no > 0) { line_no++ } next } { changelog[FNR] = $0 file_line_count = FNR } END { for (idx = 1; idx <= pr_added_count; idx++) { entry_line = pr_added_lines[idx] section_line = 0 for (i = entry_line; i >= 1; i--) { if (changelog[i] ~ /^### /) { section_line = i break } if (changelog[i] ~ /^## /) { break } } if (section_line == 0) { printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line] issue_count++ continue } section_name = changelog[section_line] next_heading = file_line_count + 1 for (i = entry_line + 1; i <= file_line_count; i++) { if (changelog[i] ~ /^### / || changelog[i] ~ /^## /) { next_heading = i break } } for (i = entry_line + 1; i < next_heading; i++) { line_text = changelog[i] if (line_text ~ /^[[:space:]]*$/) { continue } if (i in added) { continue } printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_added_text[entry_line] printf "Found existing non-added line below it at line %d: %s\n", i, line_text issue_count++ break } } if (issue_count > 0) { print "Move this PR changelog entry to the end of its section (just before the next heading)." exit 1 } } ' "$diff_file" CHANGELOG.md; then rm -f "$diff_file" exit 1 fi rm -f "$diff_file" echo "changelog placement validated: PR-linked entries are appended at section tail" if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then local with_pr_and_thanks with_pr_and_thanks=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true) if [ -z "$with_pr_and_thanks" ]; then echo "CHANGELOG.md update must include both PR #$pr and thanks @$contrib on the changelog entry line." exit 1 fi echo "changelog validated: found PR #$pr + thanks @$contrib" return 0 fi echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" } validate_changelog_merge_hygiene() { local diff diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md) local removed_lines removed_lines=$(printf '%s\n' "$diff" | awk ' /^---/ { next } /^-/ { print substr($0, 2) } ') if [ -z "$removed_lines" ]; then return 0 fi local removed_refs removed_refs=$(printf '%s\n' "$removed_lines" | rg -o '#[0-9]+' | sort -u || true) if [ -z "$removed_refs" ]; then return 0 fi local added_lines added_lines=$(printf '%s\n' "$diff" | awk ' /^\+\+\+/ { next } /^\+/ { print substr($0, 2) } ') local ref while IFS= read -r ref; do [ -z "$ref" ] && continue if ! printf '%s\n' "$added_lines" | rg -q -F "$ref"; then echo "CHANGELOG.md drops existing entry reference $ref without re-adding it." echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)." exit 1 fi done <<<"$removed_refs" echo "changelog merge hygiene validated: no dropped PR references" } changed_changelog_fragment_files() { git diff --name-only origin/main...HEAD -- changelog/fragments | rg '^changelog/fragments/.*\.md$' || true } validate_changelog_fragments_for_pr() { local pr="$1" local contrib="$2" shift 2 if [ "$#" -lt 1 ]; then echo "No changelog fragments provided for validation." exit 1 fi local pr_pattern pr_pattern="(#$pr|openclaw#$pr)" local added_lines local file local all_added_lines="" for file in "$@"; do added_lines=$(git diff --unified=0 origin/main...HEAD -- "$file" | awk ' /^\+\+\+/ { next } /^\+/ { print substr($0, 2) } ') if [ -z "$added_lines" ]; then echo "$file is in diff but no added lines were detected." exit 1 fi all_added_lines=$(printf '%s\n%s\n' "$all_added_lines" "$added_lines") done local with_pr with_pr=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" || true) if [ -z "$with_pr" ]; then echo "Changelog fragment update must reference PR #$pr (for example, (#$pr))." exit 1 fi if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then local with_pr_and_thanks with_pr_and_thanks=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true) if [ -z "$with_pr_and_thanks" ]; then echo "Changelog fragment update must include both PR #$pr and thanks @$contrib on the entry line." exit 1 fi echo "changelog fragments validated: found PR #$pr + thanks @$contrib" return 0 fi echo "changelog fragments validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" } prepare_gates() { local pr="$1" enter_worktree "$pr" false checkout_prep_branch "$pr" bootstrap_deps_if_needed require_artifact .local/pr-meta.env # shellcheck disable=SC1091 source .local/pr-meta.env local changed_files changed_files=$(git diff --name-only origin/main...HEAD) local non_docs non_docs=$(printf '%s\n' "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true) local docs_only=false if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then docs_only=true fi local has_changelog_update=false if printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then has_changelog_update=true fi local fragment_files fragment_files=$(changed_changelog_fragment_files) local has_fragment_update=false if [ -n "$fragment_files" ]; then has_fragment_update=true fi # Enforce workflow policy: every prepared PR must include either CHANGELOG.md # or one or more changelog fragments. if [ "$has_changelog_update" = "false" ] && [ "$has_fragment_update" = "false" ]; then echo "Missing changelog update. Add CHANGELOG.md changes or changelog/fragments/*.md entry." exit 1 fi local contrib="${PR_AUTHOR:-}" if [ "$has_changelog_update" = "true" ]; then validate_changelog_merge_hygiene validate_changelog_entry_for_pr "$pr" "$contrib" fi if [ "$has_fragment_update" = "true" ]; then mapfile -t fragment_file_list <<<"$fragment_files" validate_changelog_fragments_for_pr "$pr" "$contrib" "${fragment_file_list[@]}" fi run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check if [ "$docs_only" = "true" ]; then echo "Docs-only change detected with high confidence; skipping pnpm test." else run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test fi cat > .local/gates.env </dev/null || true git remote add prhead "$push_url" local remote_sha remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) if [ -z "$remote_sha" ]; then local https_url https_url=$(resolve_head_push_url_https 2>/dev/null) || true if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then echo "SSH remote failed; falling back to HTTPS..." git remote set-url prhead "$https_url" git remote set-url --push prhead "$https_url" push_url="$https_url" remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) fi if [ -z "$remote_sha" ]; then echo "Remote branch refs/heads/$PR_HEAD not found on prhead" exit 1 fi 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" local push_output if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then echo "Push failed: $push_output" # Check if this is a permission error (fork PR) vs a lease conflict. # Permission errors go straight to GraphQL; lease conflicts retry with rebase. if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..." if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then local graphql_oid graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") prep_head_sha="$graphql_oid" else echo "Git push permission denied and no fork owner/repo info for GraphQL fallback." exit 1 fi else 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 if ! git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD; then # Retry also failed — try GraphQL as last resort. if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then echo "Git push retry failed; trying GraphQL createCommitOnBranch fallback..." local graphql_oid graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") prep_head_sha="$graphql_oid" else echo "Git push failed and no fork owner/repo info for GraphQL fallback." exit 1 fi fi fi 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_sync_head() { local pr="$1" enter_worktree "$pr" false require_artifact .local/pr-meta.env require_artifact .local/prep-context.env checkout_prep_branch "$pr" # shellcheck disable=SC1091 source .local/pr-meta.env # shellcheck disable=SC1091 source .local/prep-context.env local prep_head_sha prep_head_sha=$(git rev-parse HEAD) local current_head current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName) local lease_sha lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) if [ "$current_head" != "$PR_HEAD" ]; then echo "PR head branch changed from $PR_HEAD to $current_head. Re-run prepare-init." exit 1 fi local push_url push_url=$(resolve_head_push_url) || { echo "Unable to resolve PR head repo push URL." exit 1 } # Always set prhead to the correct fork URL for this PR. # The remote is repo-level (shared across worktrees), so a previous # run for a different fork PR can leave a stale URL. git remote remove prhead 2>/dev/null || true git remote add prhead "$push_url" local remote_sha remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) if [ -z "$remote_sha" ]; then local https_url https_url=$(resolve_head_push_url_https 2>/dev/null) || true if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then echo "SSH remote failed; falling back to HTTPS..." git remote set-url prhead "$https_url" git remote set-url --push prhead "$https_url" push_url="$https_url" remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) fi if [ -z "$remote_sha" ]; then echo "Remote branch refs/heads/$PR_HEAD not found on prhead" exit 1 fi 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" local push_output if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then echo "Push failed: $push_output" if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..." if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then local graphql_oid graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") prep_head_sha="$graphql_oid" else echo "Git push permission denied and no fork owner/repo info for GraphQL fallback." exit 1 fi else echo "Lease push failed, retrying once with fresh PR head lease..." lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) pushed_from_sha="$lease_sha" if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then echo "Retry push failed: $push_output" if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then echo "Retry failed; trying GraphQL createCommitOnBranch fallback..." local graphql_oid graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") prep_head_sha="$graphql_oid" else echo "Git push failed and no fork owner/repo info for GraphQL fallback." exit 1 fi fi fi 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-sync-head 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" } is_mainline_drift_critical_path_for_merge() { local path="$1" case "$path" in package.json|pnpm-lock.yaml|pnpm-workspace.yaml|.npmrc|.oxlintrc.json|.oxfmtrc.json|tsconfig.json|tsconfig.*.json|vitest.config.ts|vitest.*.config.ts|scripts/*|.github/workflows/*) return 0 ;; esac return 1 } print_file_list_with_limit() { local label="$1" local file_path="$2" local limit="${3:-12}" if [ ! -s "$file_path" ]; then return 0 fi local count count=$(wc -l < "$file_path" | tr -d ' ') echo "$label ($count):" sed -n "1,${limit}p" "$file_path" | sed 's/^/ - /' if [ "$count" -gt "$limit" ]; then echo " ... +$((count - limit)) more" fi } mainline_drift_requires_sync() { local prep_head_sha="$1" require_artifact .local/pr-meta.json if ! git cat-file -e "${prep_head_sha}^{commit}" 2>/dev/null; then echo "Mainline drift relevance: prep head $prep_head_sha is missing locally; require sync." return 0 fi local delta_file local pr_files_file local overlap_file local critical_file delta_file=$(mktemp) pr_files_file=$(mktemp) overlap_file=$(mktemp) critical_file=$(mktemp) git diff --name-only "${prep_head_sha}..origin/main" | sed '/^$/d' | sort -u > "$delta_file" jq -r '.files[]?.path // empty' .local/pr-meta.json | sed '/^$/d' | sort -u > "$pr_files_file" comm -12 "$delta_file" "$pr_files_file" > "$overlap_file" || true local path while IFS= read -r path; do [ -n "$path" ] || continue if is_mainline_drift_critical_path_for_merge "$path"; then printf '%s\n' "$path" >> "$critical_file" fi done < "$delta_file" local delta_count local overlap_count local critical_count delta_count=$(wc -l < "$delta_file" | tr -d ' ') overlap_count=$(wc -l < "$overlap_file" | tr -d ' ') critical_count=$(wc -l < "$critical_file" | tr -d ' ') if [ "$delta_count" -eq 0 ]; then echo "Mainline drift relevance: unable to enumerate drift files; require sync." rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file" return 0 fi if [ "$overlap_count" -gt 0 ] || [ "$critical_count" -gt 0 ]; then echo "Mainline drift relevance: sync required before merge." print_file_list_with_limit "Mainline files overlapping PR touched files" "$overlap_file" print_file_list_with_limit "Mainline files touching merge-critical infrastructure" "$critical_file" rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file" return 0 fi echo "Mainline drift relevance: no overlap with PR files and no critical infra drift." print_file_list_with_limit "Mainline-only drift files" "$delta_file" rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file" return 1 } 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 if ! git merge-base --is-ancestor origin/main "pr-$pr"; then echo "PR branch is behind main." if mainline_drift_requires_sync "$PREP_HEAD_SHA"; then echo "Merge verify failed: mainline drift is relevant to this PR; refresh prep head before merge." exit 1 fi echo "Merge verify: continuing without prep-head sync because behind-main drift is unrelated." fi 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 < 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 local pr_url pr_url=$(gh pr view "$pr" --json url --jq .url) echo "merge-run complete for PR #$pr" echo "merge commit: $merge_sha" echo "merge author email: $selected_merge_author_email" echo "completion comment: $comment_url" echo "$pr_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-claim) review_claim "$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-sync-head) prepare_sync_head "$pr" ;; prepare-run) prepare_run "$pr" ;; merge-verify) merge_verify "$pr" ;; merge-run) merge_run "$pr" ;; *) usage exit 2 ;; esac } main "$@"