| #!/usr/bin/env bash |
|
|
| set -euo pipefail |
|
|
| |
| |
| 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 <<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() { |
| |
| |
| 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 |
|
|
| |
| (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 |
| } |
|
|
| 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 |
| } |
|
|
| 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 |
| |
| 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() { |
| |
| 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 |
| 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 |
| |
| source .local/review-mode.env |
| |
| 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": [], |
| "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 <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" |
| 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 <<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 |
|
|
| |
| 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" |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| 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 <<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" |
|
|
| |
| source .local/pr-meta.env |
| |
| source .local/prep-context.env |
| |
| 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 |
| 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 <<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 |
| |
| 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" |
|
|
| |
| 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" |
| |
| 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") |
| 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 <<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 "$@" |
|
|