From 3a8133d5874bff612d1ed5864279734e9535e10a Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 2 Mar 2026 18:46:01 -0800 Subject: [PATCH] fix(scripts/pr): SSH-first prhead remote with GraphQL fallback for fork PRs (#32126) Co-authored-by: Shakker --- scripts/pr | 375 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 357 insertions(+), 18 deletions(-) diff --git a/scripts/pr b/scripts/pr index 3411b1ef5b3..ebab4a85b56 100755 --- a/scripts/pr +++ b/scripts/pr @@ -28,6 +28,7 @@ Usage: 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 @@ -231,6 +232,146 @@ 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 @@ -858,13 +999,28 @@ prepare_push() { exit 1 } - git remote add prhead "$push_url" 2>/dev/null || git remote set-url prhead "$push_url" + # Always set prhead to the correct fork URL for this PR. + # The remote is repo-level (shared across worktrees), so a previous + # prepare-pr 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" | awk '{print $1}') + remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) if [ -z "$remote_sha" ]; then - echo "Remote branch refs/heads/$PR_HEAD not found on prhead" - exit 1 + 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" @@ -876,24 +1032,52 @@ prepare_push() { 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..." + 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" - lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - pushed_from_sha="$lease_sha" + # 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..." - git fetch origin "pull/$pr/head:pr-$pr-latest" --force - git rebase "pr-$pr-latest" - prep_head_sha=$(git rev-parse HEAD) + lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) + pushed_from_sha="$lease_sha" - 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 + 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 - - git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD fi fi @@ -947,6 +1131,158 @@ EOF_ENV 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" @@ -1260,6 +1596,9 @@ main() { prepare-push) prepare_push "$pr" ;; + prepare-sync-head) + prepare_sync_head "$pr" + ;; prepare-run) prepare_run "$pr" ;;