2026-02-11 13:20:57 -05:00
#!/usr/bin/env bash
set -euo pipefail
2026-02-13 15:09:37 -05:00
# 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
2026-02-11 13:20:57 -05:00
usage() {
cat <<USAGE
Usage:
scripts/pr review-init <PR>
scripts/pr review-checkout-main <PR>
scripts/pr review-checkout-pr <PR>
scripts/pr review-guard <PR>
scripts/pr review-artifacts-init <PR>
scripts/pr review-validate-artifacts <PR>
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
scripts/pr prepare-init <PR>
scripts/pr prepare-validate-commit <PR>
scripts/pr prepare-gates <PR>
scripts/pr prepare-push <PR>
scripts/pr prepare-run <PR>
scripts/pr merge-verify <PR>
scripts/pr merge-run <PR>
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() {
2026-02-13 15:09:37 -05:00
# Resolve canonical repository root from git common-dir so wrappers work
# the same from main checkout or any linked worktree.
2026-02-11 13:20:57 -05:00
local script_dir
2026-02-13 15:09:37 -05:00
local common_git_dir
2026-02-11 13:20:57 -05:00
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2026-02-13 15:09:37 -05:00
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.
2026-02-11 13:20:57 -05:00
(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 <<EOF_ENV
PR_NUMBER=$(printf '%s\n' "$json" | jq -r .number)
PR_URL=$(printf '%s\n' "$json" | jq -r .url)
PR_AUTHOR=$(printf '%s\n' "$json" | jq -r .author.login)
PR_BASE=$(printf '%s\n' "$json" | jq -r .baseRefName)
PR_HEAD=$(printf '%s\n' "$json" | jq -r .headRefName)
PR_HEAD_SHA=$(printf '%s\n' "$json" | jq -r .headRefOid)
PR_HEAD_REPO=$(printf '%s\n' "$json" | jq -r .headRepository.nameWithOwner)
PR_HEAD_REPO_URL=$(printf '%s\n' "$json" | jq -r '.headRepository.url // ""')
PR_HEAD_OWNER=$(printf '%s\n' "$json" | jq -r '.headRepositoryOwner.login // ""')
PR_HEAD_REPO_NAME=$(printf '%s\n' "$json" | jq -r '.headRepository.name // ""')
EOF_ENV
}
require_artifact() {
local path="$1"
if [ ! -s "$path" ]; then
echo "Missing required artifact: $path"
exit 1
fi
}
2026-02-12 15:10:23 -05:00
print_relevant_log_excerpt() {
local log_file="$1"
if [ ! -s "$log_file" ]; then
echo "(no output captured)"
return 0
fi
local filtered_log
filtered_log=$(mktemp)
if rg -n -i 'error|err|failed|fail|fatal|panic|exception|TypeError|ReferenceError|SyntaxError|ELIFECYCLE|ERR_' "$log_file" >"$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
}
2026-02-11 13:20:57 -05:00
bootstrap_deps_if_needed() {
if [ ! -x node_modules/.bin/vitest ]; then
2026-02-12 15:10:23 -05:00
run_quiet_logged "pnpm install --frozen-lockfile" ".local/bootstrap-install.log" pnpm install --frozen-lockfile
2026-02-11 13:20:57 -05:00
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 <<EOF_ENV
REVIEW_MODE=$mode
REVIEW_MODE_SET_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF_ENV
}
review_checkout_main() {
local pr="$1"
enter_worktree "$pr" false
git fetch origin main
2026-02-11 14:09:16 -05:00
git checkout --detach origin/main
2026-02-11 13:20:57 -05:00
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
2026-02-11 14:09:16 -05:00
git checkout --detach "pr-$pr"
2026-02-11 13:20:57 -05:00
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
2026-02-11 14:09:16 -05:00
require_artifact .local/pr-meta.env
2026-02-11 13:20:57 -05:00
# shellcheck disable=SC1091
source .local/review-mode.env
2026-02-11 14:09:16 -05:00
# shellcheck disable=SC1091
source .local/pr-meta.env
2026-02-11 13:20:57 -05:00
local branch
branch=$(git branch --show-current)
2026-02-11 14:09:16 -05:00
local head_sha
head_sha=$(git rev-parse HEAD)
2026-02-11 13:20:57 -05:00
case "${REVIEW_MODE:-}" in
main)
2026-02-11 14:09:16 -05:00
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"
2026-02-11 13:20:57 -05:00
exit 1
fi
;;
pr)
2026-02-11 14:09:16 -05:00
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"
2026-02-11 13:20:57 -05:00
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"
2026-02-11 14:09:16 -05:00
echo "head=$head_sha"
2026-02-11 13:20:57 -05:00
}
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
2026-02-12 14:55:07 -05:00
B) What changed and what is good?
2026-02-11 13:20:57 -05:00
2026-02-12 14:55:07 -05:00
C) Security findings
2026-02-11 13:20:57 -05:00
2026-02-12 14:55:07 -05:00
D) What is the PR intent? Is this the most optimal implementation?
2026-02-11 13:20:57 -05:00
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",
2026-02-12 18:07:57 -05:00
"changelog": "required"
2026-02-11 13:20:57 -05:00
}
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
2026-02-12 18:07:57 -05:00
"required")
2026-02-11 13:20:57 -05:00
;;
*)
2026-02-12 18:07:57 -05:00
echo "Invalid changelog status in .local/review.json: $changelog_status (must be \"required\")"
2026-02-11 13:20:57 -05:00
exit 1
;;
esac
echo "review artifacts validated"
}
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 list_log=".local/review-tests-list.log"
2026-02-12 15:10:23 -05:00
run_quiet_logged "pnpm vitest list" "$list_log" pnpm vitest list "$@"
2026-02-11 13:20:57 -05:00
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"
2026-02-12 15:10:23 -05:00
run_quiet_logged "pnpm vitest run" "$run_log" pnpm vitest run "$@"
2026-02-11 13:20:57 -05:00
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
}
prepare_init() {
local pr="$1"
enter_worktree "$pr" true
require_artifact .local/pr-meta.env
require_artifact .local/review.md
if [ ! -s .local/review.json ]; then
echo "WARNING: .local/review.json is missing; structured findings are expected."
fi
# shellcheck disable=SC1091
source .local/pr-meta.env
local json
json=$(pr_meta_json "$pr")
local head
head=$(printf '%s\n' "$json" | jq -r .headRefName)
local pr_head_sha_before
pr_head_sha_before=$(printf '%s\n' "$json" | jq -r .headRefOid)
if [ -n "${PR_HEAD:-}" ] && [ "$head" != "$PR_HEAD" ]; then
echo "PR head branch changed from $PR_HEAD to $head. Re-run review-pr."
exit 1
fi
git fetch origin "pull/$pr/head:pr-$pr" --force
git checkout -B "pr-$pr-prep" "pr-$pr"
git fetch origin main
git rebase origin/main
cat > .local/prep-context.env <<EOF_ENV
PR_NUMBER=$pr
PR_HEAD=$head
PR_HEAD_SHA_BEFORE=$pr_head_sha_before
PREP_BRANCH=pr-$pr-prep
PREP_STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF_ENV
if [ ! -f .local/prep.md ]; then
cat > .local/prep.md <<EOF_PREP
# PR $pr prepare log
- Initialized prepare context and rebased prep branch on origin/main.
EOF_PREP
fi
echo "worktree=$PWD"
echo "branch=$(git branch --show-current)"
echo "wrote=.local/prep-context.env .local/prep.md"
}
prepare_validate_commit() {
local pr="$1"
enter_worktree "$pr" false
require_artifact .local/pr-meta.env
checkout_prep_branch "$pr"
# shellcheck disable=SC1091
source .local/pr-meta.env
local contrib="${PR_AUTHOR:-}"
local pr_number="${PR_NUMBER:-$pr}"
if [ -z "$contrib" ]; then
contrib=$(gh pr view "$pr" --json author --jq .author.login)
fi
local subject
subject=$(git log -1 --pretty=%s)
echo "$subject" | rg -q "openclaw#$pr_number" || {
echo "ERROR: commit subject missing openclaw#$pr_number"
exit 1
}
echo "$subject" | rg -q "thanks @$contrib" || {
echo "ERROR: commit subject missing thanks @$contrib"
exit 1
}
echo "commit subject validated: $subject"
}
prepare_gates() {
local pr="$1"
enter_worktree "$pr" false
checkout_prep_branch "$pr"
bootstrap_deps_if_needed
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
2026-02-12 18:07:57 -05:00
# Enforce workflow policy: every prepared PR must include a changelog update.
if ! printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then
echo "Missing CHANGELOG.md update in PR diff. This workflow requires a changelog entry."
exit 1
fi
2026-02-12 15:10:23 -05:00
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
2026-02-11 13:20:57 -05:00
if [ "$docs_only" = "true" ]; then
echo "Docs-only change detected with high confidence; skipping pnpm test."
else
2026-02-12 15:10:23 -05:00
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
2026-02-11 13:20:57 -05:00
fi
cat > .local/gates.env <<EOF_ENV
PR_NUMBER=$pr
DOCS_ONLY=$docs_only
GATES_PASSED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF_ENV
echo "docs_only=$docs_only"
echo "wrote=.local/gates.env"
}
prepare_push() {
local pr="$1"
enter_worktree "$pr" false
require_artifact .local/pr-meta.env
require_artifact .local/prep-context.env
require_artifact .local/gates.env
checkout_prep_branch "$pr"
# shellcheck disable=SC1091
source .local/pr-meta.env
# shellcheck disable=SC1091
source .local/prep-context.env
# shellcheck disable=SC1091
source .local/gates.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
}
git remote add prhead "$push_url" 2>/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
2026-02-12 15:10:23 -05:00
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
2026-02-11 13:20:57 -05:00
if [ "${DOCS_ONLY:-false}" != "true" ]; then
2026-02-12 15:10:23 -05:00
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
2026-02-11 13:20:57 -05:00
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 <<EOF_PREP
- Gates passed and push succeeded to branch $PR_HEAD.
- Verified PR head SHA matches local prep HEAD.
- Verified PR head contains origin/main.
EOF_PREP
cat > .local/prep.env <<EOF_ENV
PR_NUMBER=$PR_NUMBER
PR_AUTHOR=$contrib
PR_HEAD=$PR_HEAD
PR_HEAD_SHA_BEFORE=$pushed_from_sha
PREP_HEAD_SHA=$prep_head_sha
COAUTHOR_EMAIL=$coauthor_email
EOF_ENV
ls -la .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
2026-02-12 15:10:23 -05:00
gh pr checks "$pr" --required --watch --fail-fast >.local/merge-checks-watch.log 2>&1 || true
2026-02-11 13:20:57 -05:00
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 <<EOF_BODY
Merged via /review-pr -> /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")
2026-02-12 15:10:23 -05:00
print_relevant_log_excerpt "$merge_output_file"
2026-02-11 13:20:57 -05:00
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 <<EOF_COMMENT
Merged via squash.
- Prepared head SHA: $PREP_HEAD_SHA
- Merge commit: $merge_sha
Thanks @$contrib!
EOF_COMMENT
); then
ok=1
break
fi
sleep 2
done
[ "$ok" -eq 1 ] || { echo "Failed to post PR comment after retries"; exit 1; }
local comment_url=""
comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
if [ -z "$comment_url" ]; then
comment_url="unresolved"
fi
local root
root=$(repo_root)
cd "$root"
git worktree remove ".worktrees/pr-$pr" --force
git branch -D "temp/pr-$pr" 2>/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 "$@"