openclaw/scripts/pr-lib/merge.sh
Peter Steinberger ad8327f586
fix(pr): requalify admission when final main objects are missing
Main can advance during prior-CI verification, leaving the final local-only
reread without its newly observed commit. Return that exact tip to bounded
pre-authority qualification instead of requiring another operator attempt.

Require Git's successful raw-object missing result with empty stderr and
readable previous/verified pins. Discard the active authority proof before
materializing, preserve the captured tip as an ancestry lower bound, and
rerun full live verification against the original proof fingerprint. Refuse
after three rounds without intent or dispatch. REST brackets stay unchanged.

Validation: original two regressions fail; all 47 final main-owner cases and both
confirmed cancelled-auto recovery cases pass, including revoked authority/evidence and
retained history. 47 REST sibling cases pass; one unchanged quota scenario returned
143 under its existing 20s fixture limit after merge/audit/comment and branch
deletions. That failed invocation is retained, not counted as full completion;
no deadline was increased. Initial truncated negative runs are also retained.
2026-09-30 10:25:35 -07:00

1190 lines
61 KiB
Bash

# shellcheck source=scripts/pr-lib/github.sh
source "$(cd "${BASH_SOURCE[0]%/*}" && pwd -P)/github.sh" || return 1
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|test/tsconfig/*|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}"
local count
count=$(wc -l < "$file_path" | tr -d ' ') || return 1
[[ "$count" =~ ^[0-9]+$ ]] || return 1
if [ "$count" -eq 0 ]; then
return 0
fi
echo "$label ($count):" || return 1
sed -n "1,${limit}p" "$file_path" | sed 's/^/ - /' || return 1
if [ "$count" -gt "$limit" ]; then
echo " ... +$((count - limit)) more" || return 1
fi
}
read_admin_landing_parent_audit() {
local landed_sha="$1" expected_parent_sha="$2" commit_file="${3:-}" response actual_parent_sha
if ! response=$(pr_gh_plain api "repos/$MERGE_REPO_NAME/commits/$landed_sha"); then
echo "Admin landing parent audit failed after merge: unable to read landed commit $landed_sha." >&2
return 1
fi
[ -z "$commit_file" ] || printf '%s\n' "$response" >"$commit_file" || return 1
actual_parent_sha=$(printf '%s\n' "$response" | jq -er '
.parents | select(type == "array" and length > 0) | .[0].sha |
select(type == "string" and test("^[0-9a-f]{40}$"))
') || {
echo "Admin landing parent audit failed after merge: landed commit has no valid first parent." >&2
return 1
}
[ "$actual_parent_sha" = "$(GIT_NO_LAZY_FETCH=1 pr_git rev-parse "$landed_sha^1")" ] || {
echo "Admin landing parent audit differs from the verified historical commit." >&2
return 1
}
jq -n --arg actualParentSha "$actual_parent_sha" --arg expectedParentSha "$expected_parent_sha" \
--arg landedSha "$landed_sha" \
'{status:(if $actualParentSha == $expectedParentSha then "match" else "drift" end),
landedSha:$landedSha,expectedParentSha:$expectedParentSha,actualParentSha:$actualParentSha}'
}
record_crabbox_landing_parent_audit() {
local landed_sha="$1" expected_parent_sha="$2"
local commit_file=".local/merge-crabbox-landed-commit.json"
local audit_file=".local/merge-crabbox-parent-audit.json" audit_tmp
if ! rm -f "$audit_file" || ! audit_tmp=$(mktemp .local/merge-crabbox-parent-audit.XXXXXX); then
echo "merge completed; post-merge audit failed: unable to prepare the landing parent artifact." >&2
return 1
fi
if ! read_admin_landing_parent_audit "$landed_sha" "$expected_parent_sha" "$commit_file" >"$audit_tmp"; then
rm -f "$audit_tmp"
return 1
fi
if ! mv "$audit_tmp" "$audit_file"; then
rm -f "$audit_tmp"
echo "merge completed; post-merge audit failed: unable to publish the landing parent artifact." >&2
return 1
fi
local actual_parent_sha
actual_parent_sha=$(jq -er .actualParentSha "$audit_file") || return 1
if [ "$actual_parent_sha" = "$expected_parent_sha" ]; then
echo "Admin landing parent audit matched: landed=$landed_sha parent=$actual_parent_sha"
else
echo "Admin landing parent audit drift: landed=$landed_sha expected_parent=$expected_parent_sha actual_parent=$actual_parent_sha"
echo "The merge already completed after intervening main movement; this audit reports the residual non-atomic race."
fi
}
# shellcheck source=scripts/pr-lib/crabbox-merge-bypass.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/crabbox-merge-bypass.sh"
# shellcheck source=scripts/pr-lib/merge-outcome.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/merge-outcome.sh"
verify_prior_ci_admin() {
local pr="$1" head="$2" expected_proof="${3:-$MERGE_PRIOR_CI_PROOF}" actor proof review_ci
[ "${MERGE_REPO_HOST:-}" = github.com ] || { echo "Prior-CI admin admission currently requires github.com." >&2; return 1; }
actor=$(pr_gh_writer_login "$MERGE_REPO_HOST") || return 1
proof=$(node "$script_parent_dir/pr-lib/merge-prior-ci.mjs" verify "$MERGE_ADMIN_EVIDENCE" "$MERGE_REPO_NAME" "$pr" "$head" "$actor" "$PR_MAIN_SHA") || return 1
review_ci=$(read_prepared_ci_failure) || return 1
if [ "$(printf '%s\n' "$proof" | jq -r .changeKind)" = pre-existing-failure ]; then
[ -n "$review_ci" ] && printf '%s\n' "$review_ci" | jq -e --argjson proof "$proof" \
'.head == $proof.head and .runId == $proof.runId and .runAttempt == $proof.runAttempt' >/dev/null || {
echo "Pre-existing CI admission requires matching failed-run attribution in the prepared review." >&2; return 1;
}
elif [ -n "$review_ci" ]; then
echo "A failing review cannot use the prior-success conflict-resolution exception." >&2
return 1
fi
if [ -n "$expected_proof" ] && [ "$expected_proof" != "$proof" ]; then
echo "Prior-CI admin evidence or authority changed during admission; no merge requested." >&2
return 1
fi
MERGE_PRIOR_CI_PROOF="$proof"
}
fetch_clawsweeper_review_comments() {
local pr="$1" repo_name="$2" repo_host="$3"
if ! CLAWSWEEPER_REVIEW_COMMENTS=$(pr_gh_plain issue-comments "$repo_name" "$repo_host" "$pr"); then
echo "ClawSweeper review gate failed: unable to read current issue comments." >&2
return 1
fi
}
validate_clawsweeper_review_comments() {
local pr="$1" head_sha="$2" evidence
if ! evidence=$(printf '%s\n' "$CLAWSWEEPER_REVIEW_COMMENTS" |
node "$script_parent_dir/pr-lib/clawsweeper-review-gate.mjs" "$pr" "$head_sha"); then
unset CLAWSWEEPER_REVIEW_COMMENTS
return 1
fi
unset CLAWSWEEPER_REVIEW_COMMENTS
CLAWSWEEPER_REVIEW_EVIDENCE="$evidence"
echo "ClawSweeper completed review: comment $(printf '%s\n' "$evidence" | jq -r .commentId), reviewed $(printf '%s\n' "$evidence" | jq -r .reviewedAt)"
}
require_clawsweeper_review() {
local pr="$1" head_sha="$2" repo_name="${3:-}" repo_host="${4:-}" repo_json
if [ -z "$repo_name" ] || [ -z "$repo_host" ]; then
repo_json=$(pr_gh_plain repo view --json nameWithOwner,url) || return 1
repo_name=$(printf '%s\n' "$repo_json" | jq -er '.nameWithOwner | select(type == "string" and length > 0)') || return 1
repo_host=$(printf '%s\n' "$repo_json" | jq -er '.url | capture("^https://(?<host>[^/]+)/").host') || return 1
fi
fetch_clawsweeper_review_comments "$pr" "$repo_name" "$repo_host" || return 1
validate_clawsweeper_review_comments "$pr" "$head_sha"
}
# Return 0 for relevant drift, 1 for unrelated drift, and 2 for failed evaluation.
# The caller uses a conditional, so every fallible evidence operation is checked.
mainline_drift_requires_sync() (
set -o pipefail
export LC_ALL=C
local mainline_base="$1"
local prepared_head_sha="$2"
local comparison_head_sha="${3:-$PR_MAIN_SHA}"
if ! GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${mainline_base}^{commit}" 2>/dev/null; then
echo "Mainline drift relevance: unable to read mainline base $mainline_base locally." >&2
return 2
fi
if ! GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${prepared_head_sha}^{commit}" 2>/dev/null; then
echo "Mainline drift relevance: unable to read prepared head $prepared_head_sha locally." >&2
return 2
fi
local scratch
scratch=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pr-drift.XXXXXX") || return 2
trap 'rm -rf -- "$scratch" || exit 2' EXIT
local delta_file="$scratch/mainline"
local prepared_files_file="$scratch/prepared"
local overlap_file="$scratch/overlap"
local critical_file="$scratch/critical"
# Compare only mainline commits since the prepared lineage base. The remote
# GraphQL commit has a different parent but its verified tree shares this
# lineage, so its PR files must not look like incoming mainline drift.
pr_git diff --name-only "${mainline_base}..${comparison_head_sha}" | sed '/^$/d' | sort -u > "$delta_file" || return 2
pr_git diff --name-only "${mainline_base}..${prepared_head_sha}" | sed '/^$/d' | sort -u > "$prepared_files_file" || return 2
comm -12 "$delta_file" "$prepared_files_file" > "$overlap_file" || return 2
: > "$critical_file" || return 2
local path read_count=0
while IFS= read -r path; do
read_count=$((read_count + 1))
[ -n "$path" ] || continue
if is_mainline_drift_critical_path_for_merge "$path"; then
printf '%s\n' "$path" >> "$critical_file" || return 2
fi
done < "$delta_file" || return 2
local delta_count
local overlap_count
local critical_count
delta_count=$(wc -l < "$delta_file" | tr -d ' ') || return 2
overlap_count=$(wc -l < "$overlap_file" | tr -d ' ') || return 2
critical_count=$(wc -l < "$critical_file" | tr -d ' ') || return 2
[[ "$delta_count" =~ ^[0-9]+$ && "$overlap_count" =~ ^[0-9]+$ && "$critical_count" =~ ^[0-9]+$ ]] || return 2
[ "$read_count" -eq "$delta_count" ] || return 2
if [ "$delta_count" -eq 0 ]; then
echo "Mainline drift relevance: no mainline changes since the prepared base." || return 2
return 1
fi
if [ "$overlap_count" -gt 0 ] || [ "$critical_count" -gt 0 ]; then
print_file_list_with_limit "Mainline files overlapping prepared files" "$overlap_file" || return 2
print_file_list_with_limit "Mainline files touching merge-critical infrastructure" "$critical_file" || return 2
echo "Mainline drift relevance: relevant input changes found." || return 2
return 0
fi
print_file_list_with_limit "Mainline-only drift files" "$delta_file" || return 2
echo "Mainline drift relevance: no overlap with prepared files and no critical infra drift." || return 2
return 1
)
verify_merge_candidate_tree() {
local main="$1" head="$2" tree main_tree
tree=$(pr_git merge-tree --write-tree "$main" "$head") || {
merge_outcome_stop "cannot establish prepared-head merge tree (conflict or unavailable objects)"; return 1;
}
main_tree=$(pr_git rev-parse "$main^{tree}") || return 1
if [ "$tree" = "$main_tree" ]; then
echo "NO NET CHANGE: squash produces the current main tree. PR lifecycle is unresolved; no merge, comment, or cleanup. Inspect main history and PR intent."
return 1
fi
}
merge_verify() {
if [ "$#" -ne 2 ]; then
echo "merge_verify requires a PR number and verification options." >&2
return 2
fi
local pr="$1" options="$2" replacement_head auto_merge_requested qualified_refusal json
if ! printf '%s\n' "$options" | jq -e '
type == "object" and keys == ["autoMergeRequested","observation","qualifiedRefusal","replacementHead"] and
(.replacementHead | type == "string") and (.autoMergeRequested | type == "boolean") and
(.qualifiedRefusal | type == "boolean") and
(.observation == null or (.observation | type == "object"))
' >/dev/null; then
echo "Invalid merge verification options: require replacementHead, autoMergeRequested, qualifiedRefusal, and observation." >&2
return 2
fi
replacement_head=$(printf '%s\n' "$options" | jq -r .replacementHead) || return 1
auto_merge_requested=$(printf '%s\n' "$options" | jq -r .autoMergeRequested) || return 1
qualified_refusal=$(printf '%s\n' "$options" | jq -r .qualifiedRefusal) || return 1
json=$(printf '%s\n' "$options" | jq -c '.observation // empty') || return 1
MERGE_USE_CRABBOX_ADMIN_BYPASS=false
enter_worktree "$pr" false || return 1
require_artifact .local/prep.env || return 1
require_artifact .local/gates.env || return 1
require_prepared_review "$pr" || return 1
local prepared_ci_failure
prepared_ci_failure=$(read_prepared_ci_failure) || return 1
if [ -n "$prepared_ci_failure" ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" != true ]; then
echo "A prepared review with pre-existing CI failures requires explicit confirmed admin admission." >&2
return 1
fi
local correction_authority correction_gate_oid=""
correction_authority=$(correction_review_snapshot "$pr") || return 1
if [ -n "$correction_authority" ]; then
require_correction_publication_gates "$pr" "$(pr_git rev-parse HEAD)" || return 1
correction_gate_oid=$(pr_git hash-object --no-filters .local/gates.env) || return 1
fi
# shellcheck disable=SC1091
source .local/gates.env || return 1
# shellcheck disable=SC1091
source .local/prep.env || return 1
if [ "${GATES_MODE:-}" = remote_crabbox_aws ]; then MERGE_TRANSPORT=graphql; fi
local github_pending=false
if [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ] &&
{ [ "${GATES_MODE:-}" != github_pending ] || [ "${HOSTED_GATES_TARGET_HEAD_SHA:-}" != "$PREP_HEAD_SHA" ]; }; then
echo "Prior-CI admin admission requires exact-head github_pending preparation." >&2
return 1
fi
if [ "${GATES_MODE:-}" = github_pending ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" != true ]; then
if { [ "$qualified_refusal" != true ] && { [ "$auto_merge_requested" != true ] || [ -n "$replacement_head" ]; }; } ||
[ "${HOSTED_GATES_TARGET_HEAD_SHA:-}" != "$PREP_HEAD_SHA" ] ||
[ "${MERGE_TRANSPORT:-graphql}" != graphql ]; then
echo "Deferred GitHub gates require --auto-merge at the exact prepared head, without recovery or REST fallback." >&2
return 1
fi
github_pending=true
fi
verify_prep_branch_matches_prepared_head "$pr" "${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}" || return 1
# GitHub publication can preserve the tree while assigning a new commit ID.
local local_tree hosted_tree
local_tree=$(pr_git rev-parse "${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}^{tree}") || return 1
hosted_tree=$(pr_git rev-parse "$PREP_HEAD_SHA^{tree}") || return 1
[ "$local_tree" = "$hosted_tree" ] || { echo "Local and hosted prepared trees differ." >&2; return 1; }
if [ -z "$json" ]; then
pr_observe "$pr" || return 1
json="$PR_OBSERVATION"
fi
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"
echo "Note: docs/changelog-only follow-ups reuse prior gate results automatically."
mark_pr_operation_side_effects_started
fetch_pr_head "$pr" "$pr_head_sha" "" "$json" >/dev/null 2>&1 || true
if GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${PREP_HEAD_SHA}^{commit}" 2>/dev/null && GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${pr_head_sha}^{commit}" 2>/dev/null; then
echo "HEAD delta (expected...current):"
pr_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
fetch_pr_head "$pr" "$PREP_HEAD_SHA" "refs/heads/pr-$pr" "$json" || return 1
json="$PR_HEAD_OBSERVATION"
require_clawsweeper_review "$pr" "$pr_head_sha" \
"${MERGE_REPO_NAME:-}" "${MERGE_REPO_HOST:-}" || return 1
mark_pr_operation_side_effects_started || return 1
if [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ]; then
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" || return 1
elif [ "$github_pending" = true ]; then
echo "GitHub will enforce required checks; submitting without a CI watcher."
elif [ "${GATES_MODE:-}" = "hosted_exact_or_recent_parent" ]; then
# The stamp selects the owner, not proof. Revalidate before skipping the
# PR-only watcher, which cannot observe accepted hosted release gates.
derive_prepare_gate_change_plan "$PREP_HEAD_SHA" || return 1
run_hosted_prepare_gates "$pr" "$PREP_HEAD_SHA" "$PREPARE_GATE_CHANGELOG_ONLY" "$json" || return 1
else
# Local/Crabbox preparation retains the attached-CI wait. Required checks
# below remain merge authority; optional contexts cannot stall this path.
local watch_args=("$pr" "$PREP_HEAD_SHA" --completion ci-run)
[ -z "${MERGE_REPO_NAME:-}" ] || watch_args+=(--repo "$MERGE_REPO_NAME")
if ! node "$script_parent_dir/watch-pr-ci.mjs" "${watch_args[@]}" >.local/merge-checks-watch.log 2>&1; then
if [ -n "$replacement_head" ]; then
echo "Replacement-head recovery requires completed CI proof; inspect .local/merge-checks-watch.log." >&2
return 1
fi
fi
fi
local checks_json checks_response checks_err_file checks_exit_status=0
checks_err_file=$(mktemp)
checks_response=$(merge_read checks "$pr" "$(printf '%s\n' "$json" | jq -er .baseRepository.url)" 2>"$checks_err_file") || checks_exit_status=$?
# gh documents exit 8 for pending checks even when it emits valid JSON. Let
# the checked evidence below reject pending checks without hiding API errors.
if [ "$checks_exit_status" -ne 0 ] && [ "$checks_exit_status" -ne 8 ]; then
echo "Merge verify failed: unable to verify the required GitHub checks." >&2
cat "$checks_err_file" >&2
rm -f "$checks_err_file"
return 1
fi
rm -f "$checks_err_file"
MERGE_TRANSPORT=$(printf '%s\n' "$checks_response" | jq -er '.transport') || return 1
checks_json=$(printf '%s\n' "$checks_response" | jq -c '.payload') || return 1
if [ "$github_pending" = true ] && [ "$MERGE_TRANSPORT" != graphql ]; then
echo "GitHub auto-merge requires GraphQL quota; no merge was requested." >&2
return 1
fi
# merge_run calls this function in an OR-list, disabling Bash errexit.
# Validate every row so malformed evidence cannot fall through as green.
if ! printf '%s\n' "$checks_json" | jq -e '
type == "array" and all(.[]; type == "object" and
(.bucket | IN("pass", "fail", "pending", "skipping", "cancel")))
' >/dev/null; then
echo "Merge verify failed: GitHub returned invalid required-check evidence." >&2
return 1
fi
local required_count
required_count=$(printf '%s\n' "$checks_json" | jq 'length') || return 1
if [ "$required_count" -eq 0 ]; then
echo "No required checks configured for this PR."
fi
if [ "$github_pending" = true ] && ! printf '%s\n' "$checks_json" | jq -e \
'any(.[]; .name == "openclaw/ci-gate")' >/dev/null; then
echo "Deferred GitHub gates require the enforced openclaw/ci-gate context." >&2
return 1
fi
printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"' || return 1
local failed_required
# gh retains the draft's skipped check beside the current CI status. Deferred
# admission still requires that same enforced gate to be pending or passing.
failed_required=$(printf '%s\n' "$checks_json" | jq --argjson deferred "$github_pending" '
. as $checks | [.[] |
select(.bucket != "pass" and .bucket != "pending") |
select(($deferred and .name == "openclaw/ci-gate" and
.bucket == "skipping" and .state == "SKIPPED" and
any($checks[]; .name == "openclaw/ci-gate" and
(.bucket == "pass" or .bucket == "pending"))) | not)
] | length
') || return 1
local pending_required
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length') || return 1
if [ "$pending_required" -gt 0 ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" != true ] && { [ "$github_pending" != true ] || [ "$qualified_refusal" = true ]; }; then
echo "Required checks are still pending."
exit 1
fi
if [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ]; then
# Only the independently qualified current CI failure may use the failure
# variant. Other checks and the original prior-green route stay strict.
printf '%s\n' "$checks_json" | jq -e --argjson proof "$MERGE_PRIOR_CI_PROOF" 'all(.[]; .bucket == "pass" or
(.name == "openclaw/ci-gate" and
(if $proof.changeKind == "pre-existing-failure" then (.bucket == "fail" or .bucket == "cancel")
else (.bucket == "pending" or .bucket == "skipping") end)))' >/dev/null || return 1
elif [ "$failed_required" -gt 0 ]; then
if [ "$github_pending" = true ]; then
echo "Required checks are failing; fix them before requesting auto-merge." >&2
return 1
fi
if [ "${MERGE_TRANSPORT:-graphql}" = rest ]; then
echo "Required checks are failing; REST fallback does not authorize an admin bypass." >&2
return 1
fi
echo "Required checks are failing; checking the bounded Crabbox infrastructure fallback."
if ! verify_crabbox_admin_merge_bypass "$pr" "$PREP_HEAD_SHA"; then
echo "Crabbox merge bypass evidence is not sufficient." >&2
echo "Required checks are failing."
exit 1
fi
MERGE_USE_CRABBOX_ADMIN_BYPASS=true
fi
refresh_main_snapshot || return 1
if ! pr_git merge-base --is-ancestor "$PR_MAIN_SHA" "refs/heads/pr-$pr"; then
echo "PR branch is behind main."
if mainline_drift_requires_sync \
"${PREP_MAINLINE_BASE_SHA:-${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}}" \
"$PREP_HEAD_SHA"
then
# GitHub enforces required checks and conflicts. Relevant drift stays
# advisory unless OPENCLAW_PR_STRICT_DRIFT=1.
if [ "${OPENCLAW_PR_STRICT_DRIFT:-}" = "1" ]; then
echo "Merge verify failed: mainline drift is relevant to this PR; run scripts/pr prepare-sync-head $pr before merge."
exit 1
fi
echo "Merge verify: WARNING — mainline drift is relevant to this PR; proceeding (OPENCLAW_PR_STRICT_DRIFT=1 restores the hard gate)."
else
local drift_status=$?
if [ "$drift_status" -ne 1 ]; then
echo "Merge verify failed: unable to evaluate mainline drift; no merge was attempted." >&2
return 1
fi
echo "Merge verify: continuing without prep-head sync because behind-main drift is unrelated."
fi
fi
verify_correction_review_snapshot "$pr" "$correction_authority" || return 1
if [ -n "$correction_authority" ]; then
[ "$correction_gate_oid" = "$(pr_git hash-object --no-filters .local/gates.env)" ] || return 1
fi
echo "merge-verify passed for PR #$pr"
}
snapshot_merge_body() {
node "${BASH_SOURCE[0]%/*}/merge-body.mjs" read "$1"
}
prepare_squash_merge_body() {
local pr="$1" captured="${2:-}" source_head="${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}"
local source_trailers author_commits authors
# GraphQL publication can collapse local fixups. Preserve their reviewed
# trailers, excluding main's ancestry, rather than inspecting current HEAD.
source_trailers=$(pr_git -c trailer.separators=: -c trailer.co-authored-by.key=Co-authored-by log --reverse \
--no-show-signature --no-notes --no-color --no-decorate --encoding=UTF-8 \
--format='%(trailers:key=Co-authored-by,only,unfold)' "$PR_MAIN_SHA..$source_head") || return 1
# A merge commit can reflect whoever refreshed the branch, not a contributor.
# Preview credit needs a tree-changing non-merge commit, PR authorship, or explicit trailer.
author_commits=$(pr_git log --no-merges --reverse --no-show-signature --no-notes \
--no-color --no-decorate --format='%H %T %P' "$PR_MAIN_SHA..$PREP_HEAD_SHA") || return 1
local repo_nwo="$MERGE_REPO_NAME" repo_host="$MERGE_REPO_HOST" preview
# A git identity alone cannot establish a human contributor. Resolve the
# published commits through GitHub, which leaves unlinked authors null.
author_commits=$(printf '%s\n' "$author_commits" | while IFS=' ' read -r oid tree parent; do
[ -n "$oid" ] || continue
parent_tree=""
[ -z "$parent" ] || parent_tree=$(pr_git rev-parse "$parent^{tree}") || exit 1
jq -cn --arg oid "$oid" --arg tree "$tree" --arg parentTree "$parent_tree" \
'{oid:$oid,changesTree:($tree != $parentTree)}' || exit 1
done) || return 1
authors=$(printf '%s\n' "$author_commits" | jq -s . | pr_gh commit-authors "$repo_nwo" "$repo_host") || return 1
preview=$(merge_read preview "$pr") || return 1
MERGE_TRANSPORT=$(printf '%s\n' "$preview" | jq -er '.transport') || return 1
preview=$(printf '%s\n' "$preview" | jq -c '.payload') || return 1
if ! printf '%s\n' "$preview" | jq -e --arg head "$PREP_HEAD_SHA" '
.data.repository.pullRequest | .headRefOid == $head and
(.viewerMergeBodyText | type == "string") and (.isMergeQueueEnabled | type == "boolean")
' >/dev/null; then
echo "Cannot preserve squash credit: require a current-head preview. Refresh prepare evidence and check the merge queue policy." >&2
return 1
fi
local queue_enabled
queue_enabled=$(printf '%s\n' "$preview" | jq -r '.data.repository.pullRequest.isMergeQueueEnabled') || return 1
if [ "$queue_enabled" = true ] && { [ -n "$source_trailers" ] || [ -n "$captured" ]; }; then
echo "Cannot preserve squash credit: body overrides require a non-queue PR." >&2
return 1
fi
local messages='[]'
if [ "$MERGE_TRANSPORT" = rest ] &&
[ "$(printf '%s\n' "$preview" | jq -r '.data.repository.squashMergeCommitMessage')" = COMMIT_MESSAGES ]; then
# Message defaults include every published commit, not only the non-merge,
# tree-changing author subset. Local-only fixups contribute trailers above.
messages=$(pr_git log --reverse --topo-order --no-show-signature --no-notes \
--no-color --no-decorate --encoding=UTF-8 -z --format=%B \
"$PR_MAIN_SHA..$PREP_HEAD_SHA" | jq -Rs 'split("\u0000")[:-1]') || return 1
fi
local body_file
body_file=$(mktemp .local/merge-body.XXXXXX) || return 1
printf '%s\n' "$preview" | jq -c \
--arg source "$source_trailers" --argjson authors "$authors" --arg captured "$captured" \
--argjson queue "$queue_enabled" --arg transport "$MERGE_TRANSPORT" --argjson messages "$messages" '
{preview:.data.repository.pullRequest.viewerMergeBodyText,prAuthor:.data.repository.pullRequest.author,source:$source,authors:$authors,captured:$captured,queue:$queue,sourceCredit:($transport == "rest"),
squashDefault:(if $transport == "rest" then {title:.data.repository.squashMergeCommitTitle,message:.data.repository.squashMergeCommitMessage,commits:$messages} else null end)}
' | node "${BASH_SOURCE[0]%/*}/merge-body.mjs" compose > "$body_file" || return 1
# Queue admission cannot accept an override, but its preview still needs validation.
if [ "$queue_enabled" = true ]; then
rm -f "$body_file" || return 1
MERGE_BODY_FILE=""
return 0
fi
MERGE_SUBJECT=""
if [ "$MERGE_TRANSPORT" = graphql ]; then
MERGE_SUBJECT=$(printf '%s\n' "$preview" | jq -er '.data.repository.pullRequest.viewerMergeHeadlineText | select(type == "string" and length > 0 and (test("[\\r\\n]") | not))') || {
echo "Cannot prepare squash subject: require the current-head merge headline." >&2; return 1;
}
fi
MERGE_BODY_FILE="$body_file"
MERGE_BODY_TRANSPORT="$MERGE_TRANSPORT"
printf '%s\n' "$body_file"
}
# Recovery approval names a reviewed head, not permission to reuse another
# head's artifacts. Subshell isolation prevents sourced stamps from changing admission.
verify_merge_recovery_artifacts() (
local pr="$1" head="$2" qualified_pending_recovery="${3:-false}"
local HOSTED_GATES_TARGET_HEAD_SHA=""
local PREP_REVIEW_MODE=""
source .local/prep-context.env || return 1
if [ "$PREP_REVIEW_MODE" = correction ]; then
# Incoming H remains the truthful NEEDS WORK identity. The correction
# owner verifies H -> local C; the publication receipt binds C -> hosted P.
require_prepared_review "$pr" || return 1
local PR_NUMBER="" PREP_HEAD_SHA="" LOCAL_PREP_HEAD_SHA=""
source .local/prep.env || return 1
[ "$PR_NUMBER" = "$pr" ] && [ "$PREP_HEAD_SHA" = "$head" ] &&
[ "$LOCAL_PREP_HEAD_SHA" = "$(pr_git rev-parse HEAD)" ] &&
[ "$(pr_git rev-parse "$LOCAL_PREP_HEAD_SHA^{tree}")" = "$(pr_git rev-parse "$head^{tree}")" ] || return 1
require_correction_publication_gates "$pr" "$LOCAL_PREP_HEAD_SHA" || return 1
return 0
fi
local PR_NUMBER="" PR_HEAD_SHA="" PR_HEAD_SHA_BEFORE=""
local PREP_HEAD_SHA="" LOCAL_PREP_HEAD_SHA="" LAST_VERIFIED_HEAD_SHA="" GATES_MODE=""
source .local/pr-meta.env || return 1
[ "$PR_NUMBER" = "$pr" ] && [ "$PR_HEAD_SHA" = "$head" ] || return 1
PR_NUMBER=""
source .local/prep-context.env || return 1
[ "$PR_NUMBER" = "$pr" ] && [ "$PR_HEAD_SHA_BEFORE" = "$head" ] || return 1
PR_NUMBER=""
source .local/prep.env || return 1
[ "$PR_NUMBER" = "$pr" ] && [ "$PREP_HEAD_SHA" = "$head" ] || return 1
[[ "$LOCAL_PREP_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || return 1
[ "$(pr_git rev-parse "$LOCAL_PREP_HEAD_SHA^{tree}")" = "$(pr_git rev-parse "$head^{tree}")" ] || return 1
PR_NUMBER=""
source .local/gates.env || return 1
[ "$PR_NUMBER" = "$pr" ] || return 1
if [ "$qualified_pending_recovery" = true ] && [ "$GATES_MODE" = github_pending ]; then
[ "$HOSTED_GATES_TARGET_HEAD_SHA" = "$head" ]
return
fi
[ "$LAST_VERIFIED_HEAD_SHA" = "$LOCAL_PREP_HEAD_SHA" ] || return 1
case "$GATES_MODE" in
full|docs_only|reused_docs_only|remote_testbox|remote_crabbox_aws|hosted_exact_or_recent_parent) ;;
*) return 1 ;;
esac
)
merge_run() {
local pr="$1"
local auto_merge_requested="${2:-false}"
local recovery_oid="${3:-}" recovery_record="" recovery_actor=""
local replacement_head="${4:-}" recovery_artifacts="" recovery_captures=()
local recovery_artifact_head="$replacement_head"
local body_path="${5:-}" captured_body="" merge_body_snapshot=""
local legacy_directory="${6:-}" legacy_refusal="" legacy_captures=()
local cancel_auto="${7:-false}"
local refusal_directory="${8:-}" refusal="" qualified_refusal=false
local provider_rejection="" qualified_pending_recovery=false retired_auto_admin=false
local MERGE_REFUSAL_DIRECTORY=""
local MERGE_ADMIN_EVIDENCE="${9:-}" confirmed_admin="${10:-false}" MERGE_PRIOR_CI_PROOF=""
local MERGE_USE_PRIOR_CI_ADMIN=false
local MERGE_PRIOR_CI_REST_OBSERVATION=false
local MERGE_PRIOR_CI_RECALCULATED=false
local MERGE_PRIOR_CI_REMATERIALIZE_MAIN=""
if [ -n "$MERGE_ADMIN_EVIDENCE" ] || [ "$confirmed_admin" = true ]; then
[ -n "$MERGE_ADMIN_EVIDENCE" ] && [ "$confirmed_admin" = true ] && [ "$auto_merge_requested" = false ] &&
[ -z "$legacy_directory$refusal_directory" ] && [ "$cancel_auto" = false ] &&
[ "${OPENCLAW_PR_MERGE_METHOD:-squash}" = squash ] || return 2
MERGE_ADMIN_EVIDENCE=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$MERGE_ADMIN_EVIDENCE") || return 1
MERGE_USE_PRIOR_CI_ADMIN=true
fi
[ -z "$refusal_directory" ] || refusal_directory=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$refusal_directory") || return 1
[ -z "$body_path" ] || body_path=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$body_path") || return 1
if [ -n "$replacement_head" ] &&
{ [ -z "$recovery_oid" ] || ! [[ "$replacement_head" =~ ^[0-9a-f]{40}$ ]]; }; then
echo "Replacement head requires an exact recovery outcome and full lowercase 40-character SHA." >&2
return 2
fi
local MERGE_OUTCOME_REF MERGE_OUTCOME_OID MERGE_OUTCOME_RECORD MERGE_REPO
local MERGE_REPO_URL MERGE_REPO_HOST MERGE_REPO_NAME MERGE_OBSERVATION MERGE_ENTRY_OBSERVATION MERGE_TRANSPORT=rest
merge_outcome_init "$pr" || return 1
if [ "$cancel_auto" = true ]; then
[ -n "$recovery_oid" ] && [ -z "$replacement_head$body_path$legacy_directory$refusal_directory" ] && [ "$auto_merge_requested" = false ] || return 2
merge_outcome_cancel_auto "$pr" "$recovery_oid"
return
fi
if [ -n "$legacy_directory" ]; then
[ -z "$MERGE_OUTCOME_OID" ] && [ -n "$recovery_oid" ] && [ -n "$replacement_head" ] || {
merge_outcome_stop "legacy recovery requires no recorded outcome, the pinned original capture, and an explicit current head"; return 1;
}
elif [ -n "$recovery_oid" ]; then
if [ "$recovery_oid" != "$MERGE_OUTCOME_OID" ] ||
! printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -e --arg refusal "$refusal_directory" --arg replacement "$replacement_head" --argjson admin "$MERGE_USE_PRIOR_CI_ADMIN" '
.phase == "intent" and
(if $admin then .method == "squash" and
(if $replacement != "" then .route == "auto" and .cancellation.state == "confirmed"
else .accepted == false and .route == "admin" and .priorCiAdmin.dispatchTransport == "rest" end)
else (.accepted == false and (.route == "immediate" or ($refusal != "" and .route == "auto" and .method == "squash"))) or
(.route == "auto" and .cancellation.state == "confirmed") end)
' >/dev/null; then
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
merge_outcome_stop "operator admin recovery requires an exact rejected prior-CI intent or a confirmed auto cancellation with an explicit selected head; no attempt was authorized"
else
merge_outcome_stop "operator recovery requires the exact unaccepted immediate intent or confirmed auto cancellation; no attempt was authorized"
fi
return 1
fi
recovery_record="$MERGE_OUTCOME_RECORD"
elif [ -n "$refusal_directory" ]; then
merge_outcome_stop "pre-dispatch qualification requires explicit operator recovery"; return 1
elif [ -n "$MERGE_OUTCOME_OID" ]; then
# Reconciliation needs neither the old worktree nor its prepare artifacts.
merge_outcome_resume "$pr"
return
fi
if [ "$auto_merge_requested" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] || [ "${OPENCLAW_PR_MERGE_METHOD:-squash}" != squash ]; then
MERGE_TRANSPORT=graphql
fi
review_artifact_preflight "$pr" prepared || return 1
# Capture before gates or cwd changes; retained outcomes above reconcile even
# when the original operator file no longer exists.
if [ -n "$body_path" ]; then
[ "${OPENCLAW_PR_MERGE_METHOD:-squash}" = squash ] || {
echo "--body-file requires squash merge." >&2; return 2;
}
captured_body=$(snapshot_merge_body "$body_path") || return 1
fi
enter_worktree "$pr" false || return 1
# Earlier wrappers captured output at dispatch without recording intent. Even
# an empty capture may represent a submitted request; never overwrite that evidence.
if [ -z "$recovery_oid" ] && has_worktree_merge_output .; then
merge_outcome_stop "prior merge output exists without an outcome record; preserve the captures and reconcile the earlier request manually"
return 1
fi
if [ -n "$refusal_directory" ]; then
[ -z "$legacy_directory" ] || return 2
refusal=$(node "$script_parent_dir/pr-lib/merge-pre-dispatch-refusal.mjs" "$refusal_directory" "$recovery_oid" "$recovery_record") || return 1
qualified_refusal=true
qualified_pending_recovery=true
MERGE_REFUSAL_DIRECTORY="$refusal_directory"
MERGE_TRANSPORT=graphql
if [ -z "$recovery_artifact_head" ]; then
recovery_artifact_head=$(printf '%s\n' "$recovery_record" | jq -er .head) || return 1
fi
fi
if [ -n "$recovery_oid" ] && [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
if [ -n "$replacement_head" ]; then
# The retained cancellation qualified this transition; current-head evidence
# still passes both live admin checks before the successor intent is written.
retired_auto_admin=true
else
provider_rejection=$(node "$script_parent_dir/pr-lib/merge-prior-ci.mjs" provider-rejection "$recovery_record" .local) || return 1
recovery_artifact_head=$(printf '%s\n' "$recovery_record" | jq -er .head) || return 1
fi
qualified_pending_recovery=true
fi
local required required_artifacts=(
.local/review.json
.local/pr-meta.env
.local/pr-meta.json
.local/prep.md
.local/prep.env
)
[ -z "$recovery_artifact_head" ] || required_artifacts+=(.local/prep-context.env .local/gates.env)
local correction_authority="" correction_gates_oid=""
correction_authority=$(correction_review_snapshot "$pr") || return 1
if [ -n "$correction_authority" ]; then
required_artifacts+=(.local/prep-context.env .local/gates.env
.local/correction-review.json
.local/correction-incoming-review.json)
correction_gates_oid=$(pr_git hash-object --no-filters .local/gates.env) || return 1
fi
for required in "${required_artifacts[@]}"; do
require_artifact "$required" || return 1
done
if [ -n "$replacement_head$provider_rejection" ]; then
local capture
for capture in .local/merge-output.log .local/merge-output.*.log; do
[ -e "$capture" ] || [ -L "$capture" ] || continue
[ -f "$capture" ] && [ ! -L "$capture" ] || { merge_outcome_stop "cannot retain non-regular capture $capture"; return 1; }
recovery_captures+=("$capture")
required_artifacts+=("$capture")
done
fi
if [ -n "$recovery_artifact_head" ]; then
recovery_artifacts=$(pr_git hash-object --no-filters -- "${required_artifacts[@]}") || return 1
if ! verify_merge_recovery_artifacts "$pr" "$recovery_artifact_head" "$qualified_pending_recovery"; then
merge_outcome_stop "recovery requires matching PR, freshly reviewed prepare context, prepared tree, and valid gate bindings; re-run review and prepare"
return 1
fi
fi
if [ -n "$legacy_directory" ]; then
legacy_refusal=$(node "$script_parent_dir/pr-lib/merge-legacy-refusal.mjs" "$legacy_directory" "$recovery_oid" "$MERGE_REPO_NAME" "$pr" "$MERGE_REPO_URL") || return 1
local name
for name in gates.env merge-output.log prep.env prep.md; do legacy_captures+=("$legacy_directory/$name"); done
for name in $(printf '%s\n' "$legacy_refusal" | jq -r '.head,.preparedBase'); do
GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "$name^{commit}" || { merge_outcome_stop "legacy source objects unavailable"; return 1; }
done
fi
validate_review_artifact_data || return 1
require_prepared_review "$pr" || return 1
local verify_options
verify_options=$(jq -cn --arg replacementHead "$replacement_head" \
--argjson autoMergeRequested "$auto_merge_requested" --argjson observation "$MERGE_ENTRY_OBSERVATION" \
--argjson qualifiedRefusal "$qualified_refusal" \
'{replacementHead:$replacementHead,autoMergeRequested:$autoMergeRequested,qualifiedRefusal:$qualifiedRefusal,observation:$observation}') || return 1
merge_verify "$pr" "$verify_options" || return 1
MERGE_ENTRY_OBSERVATION="$PR_HEAD_OBSERVATION"
# shellcheck disable=SC1091
source .local/prep.env
local merge_method="${OPENCLAW_PR_MERGE_METHOD:-squash}"
if [ -n "$recovery_record" ] && ! printf '%s\n' "$recovery_record" | jq -e \
--arg head "$PREP_HEAD_SHA" --arg method "$merge_method" --arg replacement "$replacement_head" \
'(.head == $head or ($replacement == $head and $replacement != "")) and .method == $method' >/dev/null; then
merge_outcome_stop "operator recovery requires the retained prepared head (or explicit replacement head) and merge method"
return 1
fi
local merge_flag
case "$merge_method" in
squash)
merge_flag="--squash"
;;
merge)
merge_flag="--merge"
;;
rebase)
merge_flag="--rebase"
;;
*)
echo "Invalid OPENCLAW_PR_MERGE_METHOD: $merge_method (expected squash, merge, or rebase)."
exit 2
;;
esac
if [ "$merge_method" = "squash" ]; then
case "${PREP_REPLACED_HOSTED_ANCESTRY:-}" in
true | false) ;;
*)
echo "Missing or invalid squash ancestry provenance; re-run scripts/pr prepare-run $pr."
return 1
;;
esac
case "${PREP_AUTHOR_ACCESS:-}" in
maintainer | external | unknown) ;;
*)
echo "Missing or invalid PR author access provenance; re-run scripts/pr prepare-run $pr."
return 1
;;
esac
if [ "$PREP_REPLACED_HOSTED_ANCESTRY" = "true" ] && [ "$PREP_AUTHOR_ACCESS" != "maintainer" ]; then
echo "Refusing to squash a contributor-owned PR after maintainer ancestry replacement."
echo "Create a maintainer-owned replacement PR, link the original, and preserve public noreply co-author credit."
return 1
fi
fi
if [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = "true" ] && [ "$merge_method" != "squash" ]; then
echo "Crabbox infrastructure bypass requires the pinned squash merge method."
exit 2
fi
if [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = "true" ] && [ "$auto_merge_requested" = "true" ]; then
echo "Crabbox infrastructure bypass uses an immediate pinned admin squash merge; ignoring auto-merge."
auto_merge_requested=false
fi
if [ "$auto_merge_requested" = "true" ] && [ "$merge_method" != "squash" ]; then
echo "Auto-merge requires squash; unset OPENCLAW_PR_MERGE_METHOD or set it to squash."
exit 2
fi
local merge_args=(--match-head-commit "$PREP_HEAD_SHA")
local MERGE_BODY_FILE="" MERGE_BODY_TRANSPORT=graphql MERGE_SUBJECT=""
if [ "$merge_method" = "squash" ]; then
local merge_body_file
prepare_squash_merge_body "$pr" "$captured_body" >/dev/null || return 1
merge_body_file="$MERGE_BODY_FILE"
if [ -n "$merge_body_file" ]; then
merge_body_snapshot=$(snapshot_merge_body "$merge_body_file") || return 1
fi
fi
local crabbox_final_main_sha="" route=immediate
local MERGE_ADMISSION_ACTIVE=true MERGE_PRIOR_CI_OBSERVED_MAIN=""
local admission_attempt previous_observation=""
# Only fresh admission waits for calculation; retained intent reconciles immediately.
# Pin PR/policy facts and each projection as soon as it becomes known.
for admission_attempt in 1 2 3; do
merge_outcome_observe "$pr" || return 1
if [ "$MERGE_TRANSPORT" = rest ] && [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ] &&
{ [ "$merge_method" != squash ] || [ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; }; then
merge_outcome_stop "REST fallback supports ordinary immediate squash only; auto, queue, and admin routes require GraphQL"
return 1
fi
if ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e --arg head "$PREP_HEAD_SHA" \
--argjson source "$MERGE_ENTRY_OBSERVATION" --argjson recovery "${recovery_record:-null}" '
.pr.state == "OPEN" and .pr.headRefOid == $head and .pr.baseRefName == "main" and
.pr.headRefOid == $source.headRefOid and ($source.headRefName | type == "string" and length > 0) and
.pr.isDraft == false and .pr.mergeable != "CONFLICTING" and
.pr.autoMergeRequest == null and .pr.isInMergeQueue == false and
($recovery == null or .pr.id == $recovery.prId)
' >/dev/null; then
printf 'Merge admission rejected (observation %s, prepared head %s): %s\n' \
"$admission_attempt" "$PREP_HEAD_SHA" "$MERGE_OBSERVATION" >&2
merge_outcome_diagnose "$pr" "$MERGE_OBSERVATION"
merge_outcome_stop "require OPEN, exact prepared head, main base, non-draft, no conflicts, and no existing auto/queue request; inspect current PR state"
return 1
fi
if [ -n "$previous_observation" ] && ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e --argjson previous "$previous_observation" --argjson admin "$MERGE_USE_CRABBOX_ADMIN_BYPASS" '
def facts: del(.pr.mergeable,.pr.mergeStateStatus) |
if $admin then . else del(.main) end;
(if .transport != $previous.transport then
(facts | del(.transport,.restPolicy)) == ($previous | facts | del(.transport,.restPolicy))
else facts == ($previous | facts) end) and
($previous.pr.mergeable == "UNKNOWN" or .pr.mergeable == $previous.pr.mergeable) and
($previous.pr.mergeStateStatus == "UNKNOWN" or .pr.mergeStateStatus == $previous.pr.mergeStateStatus)
' >/dev/null; then
local pinned_observation
pinned_observation=$(printf '%s\n' "$previous_observation" | jq -c --argjson current "$MERGE_OBSERVATION" '
if .pr.mergeable == "UNKNOWN" then .pr.mergeable=$current.pr.mergeable else . end |
if .pr.mergeStateStatus == "UNKNOWN" then .pr.mergeStateStatus=$current.pr.mergeStateStatus else . end
')
merge_outcome_diagnose "$pr" "$MERGE_OBSERVATION" "$pinned_observation"
merge_outcome_stop "PR or main changed while waiting for mergeability; stopped before intent/dispatch"
return 1
fi
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] && [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = false ]; then
verify_prior_ci_main_advance \
"$(printf '%s\n' "${previous_observation:-$MERGE_OBSERVATION}" | jq -r .main)" \
"$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .main)" || return 1
fi
if printf '%s\n' "$MERGE_OBSERVATION" | jq -e '.pr.mergeable != "UNKNOWN" and .pr.mergeStateStatus != "UNKNOWN"' >/dev/null; then
break
fi
if [ "$admission_attempt" -eq 3 ]; then
local known_projections
known_projections=$(printf '%s\n' "$MERGE_OBSERVATION" | jq -c \
'.pr |= with_entries(if (.key == "mergeable" or .key == "mergeStateStatus") and .value == "UNKNOWN" then .value="known (not UNKNOWN)" else . end)')
merge_outcome_diagnose "$pr" "$MERGE_OBSERVATION" "$known_projections"
merge_outcome_stop "mergeability remained UNKNOWN after 3 observations; stopped before intent/dispatch"
return 1
fi
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] && [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = false ] &&
[ "$MERGE_TRANSPORT" = graphql ]; then
# Pin the alternate reader; the next whole observation must preserve every known fact.
MERGE_PRIOR_CI_REST_OBSERVATION=true
fi
if [ "$admission_attempt" -eq 1 ]; then
echo "Waiting for GitHub mergeability to settle (up to 3 observations, waiting 1 then 2 seconds for UNKNOWN samples)."
fi
previous_observation="$MERGE_OBSERVATION"
sleep "$admission_attempt"
done
if [ "$MERGE_TRANSPORT" = rest ] && [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ] &&
[ "$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .pr.mergeStateStatus)" != CLEAN ]; then
merge_outcome_stop "REST fallback requires a CLEAN merge projection without bypass"
return 1
fi
if [ "$MERGE_TRANSPORT" = rest ] && [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ]; then
# Quota can expire after the first preview. Compose source credit through
# the same owner before selecting a REST mutation or retaining its intent.
if [ "$MERGE_BODY_TRANSPORT" != rest ]; then
prepare_squash_merge_body "$pr" "$captured_body" >/dev/null || return 1
rm -f "$merge_body_file" || return 1
merge_body_file="$MERGE_BODY_FILE"
fi
merge_body_snapshot=$(snapshot_merge_body "$merge_body_file") || return 1
fi
if [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
route="admin"
merge_args=(--admin "${merge_args[@]}")
elif [ "$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .pr.isMergeQueueEnabled)" = true ]; then
route=queue
elif [ "$auto_merge_requested" = true ]; then
# Select once before intent; CLEAN needs no auto request. No dispatch error
# can authorize a second route or request.
case "$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r '.pr | .mergeable + "/" + .mergeStateStatus')" in
MERGEABLE/CLEAN) ;;
MERGEABLE/BEHIND|MERGEABLE/BLOCKED)
route=auto
merge_args=(--auto "${merge_args[@]}")
;;
*)
merge_outcome_diagnose "$pr" "$MERGE_OBSERVATION" null "CLEAN|BEHIND|BLOCKED" "MERGEABLE"
merge_outcome_stop "auto-merge admission requires MERGEABLE with CLEAN, BEHIND, or BLOCKED status"; return 1 ;;
esac
fi
if [ -n "$captured_body" ] && [ "$route" = queue ]; then
merge_outcome_stop "--body-file requires a non-queue PR"
return 1
fi
if [ -n "$recovery_oid" ] && [ "$route" != immediate ] &&
{ [ "$route" != admin ] || { [ -z "$provider_rejection" ] && [ "$retired_auto_admin" != true ]; }; }; then
merge_outcome_stop "operator recovery requires current immediate admission without admin, auto, or queue routing"
return 1
fi
if [ "$route" = immediate ] && [ "$merge_method" = squash ] && [ -z "$merge_body_snapshot" ]; then
merge_outcome_stop "ordinary squash requires the captured merge body; queue policy changed during admission"
return 1
fi
if [ "$qualified_refusal" = true ] && ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e '.pr.mergeable == "MERGEABLE" and .pr.mergeStateStatus == "CLEAN"' >/dev/null; then
merge_outcome_stop "qualified refusal recovery requires MERGEABLE/CLEAN immediate admission"; return 1
fi
# gh skips local status refusals for queue-enabled PRs; admin bypasses BLOCKED/BEHIND.
# Reject known client-side refusals before recording non-retryable intent.
if printf '%s\n' "$MERGE_OBSERVATION" | jq -e --arg route "$route" '
.pr | .isMergeQueueEnabled == false and
(.mergeStateStatus == "DIRTY" or ($route == "immediate" and (.mergeStateStatus | IN("BLOCKED", "BEHIND"))))
' >/dev/null; then
local allowed_status="not DIRTY"
[ "$route" != immediate ] || allowed_status="not BLOCKED|BEHIND|DIRTY"
merge_outcome_diagnose "$pr" "$MERGE_OBSERVATION" null "$allowed_status"
merge_outcome_stop "selected merge route is blocked by policy, branch drift, or a dirty merge projection; inspect current PR state"
return 1
fi
local observed_main
observed_main=$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .main)
if [ "$merge_method" = squash ] && [ "$route" != queue ]; then
verify_merge_candidate_tree "$observed_main" "$PREP_HEAD_SHA" || return 1
fi
if [ -n "$recovery_oid" ]; then
recovery_actor=$(pr_gh_writer_login "$MERGE_REPO_HOST") || return 1
[ -n "$recovery_actor" ] || { merge_outcome_stop "cannot identify the operator recovery actor"; return 1; }
fi
# Ordinary squash has one final authority observation after comment collection.
# Special routes retain their separate admission and admin authority windows.
if [ "$route" != immediate ] || [ "$merge_method" != squash ]; then
merge_outcome_stable "$pr" || return 1
fi
if [ "$route" = admin ] && [ "$MERGE_USE_PRIOR_CI_ADMIN" != true ]; then
verify_crabbox_admin_merge_bypass "$pr" "$PREP_HEAD_SHA" || return 1
crabbox_final_main_sha=$(jq -er '.mainSha | select(type == "string" and test("^[0-9a-f]{40}$"))' .local/merge-crabbox-bypass.json) || return 1
[ "$crabbox_final_main_sha" = "$observed_main" ] || {
merge_outcome_stop "main changed during final admin admission"; return 1;
}
fi
fetch_clawsweeper_review_comments "$pr" "$MERGE_REPO_NAME" "$MERGE_REPO_HOST" || return 1
if ! merge_outcome_stable "$pr"; then
unset CLAWSWEEPER_REVIEW_COMMENTS
return 1
fi
validate_clawsweeper_review_comments "$pr" "$PREP_HEAD_SHA" || return 1
if [ -n "$merge_body_snapshot" ] &&
[ "$merge_body_snapshot" != "$(snapshot_merge_body "$merge_body_file")" ]; then
merge_outcome_stop "merge body changed during admission; no request was dispatched"
return 1
fi
if [ -n "$legacy_directory" ] &&
[ "$legacy_refusal" != "$(node "$script_parent_dir/pr-lib/merge-legacy-refusal.mjs" "$legacy_directory" "$recovery_oid" "$MERGE_REPO_NAME" "$pr" "$MERGE_REPO_URL")" ]; then
merge_outcome_stop "legacy evidence changed during admission"; return 1
fi
# A final stability read can exhaust GraphQL after route/body selection.
# Revalidate the selected route before retaining any REST mutation intent.
if [ "$MERGE_TRANSPORT" = rest ]; then
if [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = true ]; then
[ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] && [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = false ] &&
[ "$route" = admin ] && [ "$merge_method" = squash ] && [ "$auto_merge_requested" = false ] || return 1
elif [ "$merge_method" != squash ] || [ "$route" != immediate ] ||
[ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
merge_outcome_stop "REST fallback supports ordinary immediate squash only; auto, queue, and admin routes require GraphQL"
return 1
fi
if [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ] &&
{ [ -z "$merge_body_snapshot" ] || ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e '
.pr.mergeable == "MERGEABLE" and .pr.mergeStateStatus == "CLEAN"
' >/dev/null; }; then
merge_outcome_stop "REST fallback requires a CLEAN merge projection and verified squash body"
return 1
fi
fi
if [ -n "$refusal_directory" ] &&
[ "$refusal" != "$(node "$script_parent_dir/pr-lib/merge-pre-dispatch-refusal.mjs" "$refusal_directory" "$recovery_oid" "$recovery_record")" ]; then
merge_outcome_stop "pre-dispatch evidence changed during admission"; return 1
fi
# A quota-driven transport change can replace the prepared body file. Bind
# GraphQL dispatch to the final file, after all admission reads have settled.
[ -z "${merge_body_file:-}" ] || merge_args+=(--body-file "$merge_body_file")
if [ "$MERGE_TRANSPORT" = graphql ] && [ "$merge_method" = squash ] && [ "$route" != queue ]; then
merge_args+=(--subject "$MERGE_SUBJECT")
fi
verify_correction_review_snapshot "$pr" "$correction_authority" || return 1
if [ -n "$correction_authority" ]; then
[ "$correction_gates_oid" = "$(pr_git hash-object --no-filters .local/gates.env)" ] || return 1
require_correction_publication_gates "$pr" "$(pr_git rev-parse HEAD)" || return 1
fi
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
local authority_round authority_result expected_prior_ci_proof="$MERGE_PRIOR_CI_PROOF"
for authority_round in 1 2 3; do
# Retain only the equality fingerprint, never a prior live authority decision.
MERGE_PRIOR_CI_PROOF=""
merge_outcome_stable "$pr" false requalify-prior-ci || return 1
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" "$expected_prior_ci_proof" || return 1
[ "$MERGE_PRIOR_CI_REST_OBSERVATION" != true ] || break
# GraphQL retains its post-authority stability check without fetching.
# REST already brackets policy and main before the final authority check.
MERGE_PRIOR_CI_RECALCULATED=false
authority_result=0
GIT_NO_LAZY_FETCH=1 merge_outcome_stable "$pr" true requalify-prior-ci || authority_result=$?
if [ "$authority_result" -eq 75 ] && [ -n "$MERGE_PRIOR_CI_REMATERIALIZE_MAIN" ]; then
MERGE_PRIOR_CI_PROOF=""
if [ "$authority_round" -eq 3 ]; then
merge_outcome_stop "prior-CI main kept advancing after 3 authority rounds; stopped before intent/dispatch"
return 1
fi
echo "Requalifying prior-CI admission after main $MERGE_PRIOR_CI_REMATERIALIZE_MAIN (round $((authority_round + 1))/3)."
# The previous authority decision is discarded before any materialization.
verify_prior_ci_main_advance "$MERGE_PRIOR_CI_OBSERVED_MAIN" "$MERGE_PRIOR_CI_REMATERIALIZE_MAIN" || return 1
continue
fi
[ "$authority_result" -eq 0 ] || return 1
if [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = true ] || [ "$MERGE_PRIOR_CI_RECALCULATED" = true ]; then
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" || return 1
fi
break
done
# No awaited operation may replace the operator's bytes after validation.
node "$script_parent_dir/pr-lib/merge-prior-ci.mjs" unchanged \
"$MERGE_ADMIN_EVIDENCE" "$(printf '%s\n' "$MERGE_PRIOR_CI_PROOF" | jq -r .evidenceSha256)" >/dev/null || return 1
crabbox_final_main_sha="$observed_main"
fi
if [ -n "$provider_rejection" ] &&
[ "$provider_rejection" != "$(node "$script_parent_dir/pr-lib/merge-prior-ci.mjs" provider-rejection "$recovery_record" .local)" ]; then
merge_outcome_stop "provider rejection evidence changed during admission"; return 1
fi
if [ -n "$recovery_artifact_head" ]; then
if [ "$recovery_artifacts" != "$(pr_git hash-object --no-filters -- "${required_artifacts[@]}")" ]; then
merge_outcome_stop "recovery artifacts changed during admission"
return 1
fi
GIT_NO_LAZY_FETCH=1 verify_prep_branch_matches_prepared_head "$pr" "$LOCAL_PREP_HEAD_SHA" || return 1
fi
local intent attempt
attempt=$(node -e 'process.stdout.write(require("node:crypto").randomUUID())') || return 1
intent=$(printf '%s\n' "$MERGE_OBSERVATION" | jq -c --argjson repo "$MERGE_REPO" \
--arg method "$merge_method" --arg route "$route" --arg attempt "$attempt" \
--arg localHead "${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}" --arg transport "$MERGE_TRANSPORT" \
--argjson review "$CLAWSWEEPER_REVIEW_EVIDENCE" '
{version:1,repo:$repo,pr:.pr.number,prId:.pr.id,base:.pr.baseRefName,head:.pr.headRefOid,
localHead:$localHead,
main:.main,method:$method,route:$route,attempt:$attempt,phase:"intent",accepted:false,landed:null,
clawsweeperReview:$review} + (if $transport == "rest" then {transport:"rest"} else {} end)
') || return 1
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
intent=$(printf '%s\n' "$intent" | jq -c --argjson proof "$MERGE_PRIOR_CI_PROOF" '.priorCiAdmin=$proof') || return 1
fi
if [ -n "$legacy_directory" ]; then
intent=$(printf '%s\n' "$intent" | jq -c --argjson legacy "$legacy_refusal" --arg actor "$recovery_actor" '.legacyRefusal=($legacy + {actor:$actor})') || return 1
elif [ -n "$recovery_oid" ]; then
# The CAS consumes this explicit operator decision and retains the old intent.
# A separately qualified provider rejection never authorizes automatic retries.
intent=$(printf '%s\n' "$intent" | jq -c --arg outcome "$recovery_oid" \
--argjson previous "$recovery_record" --arg actor "$recovery_actor" --arg replacement "$replacement_head" \
'.recovery=({outcome:$outcome,attempt:$previous.attempt,actor:$actor,reason:"explicit-operator-recovery"} +
if $replacement == "" then {} else {replacementHead:$replacement} end)') || return 1
fi
if [ -n "$refusal" ]; then
intent=$(printf '%s\n' "$intent" | jq -c --argjson refusal "$refusal" '.recovery.preDispatchRefusal=$refusal') || return 1
fi
if [ -n "$provider_rejection" ]; then
intent=$(printf '%s\n' "$intent" | jq -c --argjson rejection "$provider_rejection" '.recovery.providerRejection=$rejection') || return 1
fi
mark_pr_operation_side_effects_started
MERGE_ADMISSION_ACTIVE=false
if [ -n "$legacy_directory" ]; then
merge_outcome_write "$intent" "${legacy_captures[@]}" || return 1
else
merge_outcome_write "$intent" ${recovery_captures[@]+"${recovery_captures[@]}"} || return 1
fi
local merge_output=".local/merge-output.$attempt.log"
# Both success and failure are reconciled. A killed process leaves intent for
# the next invocation; an OPEN read can never authorize another dispatch. Each
# attempt owns an exclusive capture, so recovery cannot overwrite earlier evidence.
if (
set -o noclobber
exec >"$merge_output" || exit 125
exec 2>&1
export OCTOPOOL_DIAGNOSTICS=1
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
merge_outcome_dispatch_prior_ci_squash "$merge_body_snapshot" "$MERGE_SUBJECT"
elif [ "$MERGE_TRANSPORT" = rest ]; then
merge_rest merge "$pr" "$PREP_HEAD_SHA" "$merge_body_snapshot" "$MERGE_OBSERVATION"
elif [ "$route" = immediate ] && [ "$merge_method" = squash ]; then
merge_outcome_dispatch_squash "$merge_body_snapshot"
else
pr_gh_plain pr merge "$pr" --repo "$MERGE_REPO_URL" "$merge_flag" "${merge_args[@]}"
fi
); then
merge_outcome_write "$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -c '.accepted=true')" || return 1
else
# Do not read a capture we could not create; it may be somebody else's symlink.
[ "$?" -eq 125 ] || print_relevant_log_excerpt "$merge_output"
fi
merge_outcome_reconcile "$pr" || return 1
[ "$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r .phase)" != intent ] || return 0
local landed_sha
landed_sha=$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r .landed)
if [ "$route" = admin ]; then
record_crabbox_landing_parent_audit "$landed_sha" "$crabbox_final_main_sha" || return 1
fi
local comment_body MERGE_COMPLETION_COMMENT_URL
comment_body=$(merge_outcome_comment_body "$pr") || return 1
if [ "$MERGE_USE_PRIOR_CI_ADMIN" != true ] && [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = "true" ]; then
local crabbox_check_url
local ci_gate_url
crabbox_check_url=$(jq -r .crabboxCheckUrl .local/merge-crabbox-bypass.json)
ci_gate_url=$(jq -r .ciGateUrl .local/merge-crabbox-bypass.json)
printf -v comment_body \
'%s\n- Alternate gate: [openclaw/crabbox-gate](%s)\n- Hosted CI infrastructure failure: [openclaw/ci-gate](%s)\n- Landing parent audit: %s (expected `%s`, actual `%s`)' \
"$comment_body" \
"$crabbox_check_url" \
"$ci_gate_url" \
"$(jq -r 'if .status == "match" then "match" else "drift after intervening main movement; merge already completed" end' .local/merge-crabbox-parent-audit.json)" \
"$(jq -r .expectedParentSha .local/merge-crabbox-parent-audit.json)" \
"$(jq -r .actualParentSha .local/merge-crabbox-parent-audit.json)"
fi
merge_outcome_post_comment "$pr" "$comment_body" || return 1
# Only this uninterrupted completion path owns cleanup. The exact-head lease
# protects advanced/different-head recreations, but cannot detect same-SHA recreation.
local MERGE_HEAD_REF MERGE_HEAD_REPO cleanup_complete=true
if merge_outcome_head_branch "$pr"; then
local cleanup_error ref_status=0
if ! cleanup_error=$(pr_git push --force-with-lease="refs/heads/$MERGE_HEAD_REF:$PREP_HEAD_SHA" \
"https://$MERGE_REPO_HOST/$MERGE_HEAD_REPO.git" ":refs/heads/$MERGE_HEAD_REF" 2>&1); then
# GitHub may already have deleted the branch, or the delete response was
# lost. Only a successful advertisement with no exact ref proves absence.
pr_git ls-remote --exit-code --refs "https://$MERGE_REPO_HOST/$MERGE_HEAD_REPO.git" "refs/heads/$MERGE_HEAD_REF" >/dev/null || ref_status=$?
if [ "$ref_status" -ne 2 ]; then
cleanup_complete=false
echo "Warning: remote cleanup pending; branch changed or inaccessible. Inspect $MERGE_HEAD_REPO:$MERGE_HEAD_REF; never delete it by name without verifying ownership."
printf '%s\n' "$cleanup_error" >&2
fi
fi
else
cleanup_complete=false
echo "Warning: remote cleanup pending; unable to verify head branch metadata."
fi
local root
root=$(repo_root)
cd "$root" || return 1
cleanup_pr_worktree ".worktrees/pr-$pr" || cleanup_complete=false
if [ "$cleanup_complete" = true ]; then
merge_outcome_write "$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -c '.phase="complete"')" || return 1
echo "merge-run complete for PR #$pr"
else
echo "Merge confirmed; completion pending: inspect cleanup warnings. Recovery will not delete branches or worktrees."
fi
echo "landed commit: $landed_sha"
echo "completion comment: $MERGE_COMPLETION_COMMENT_URL"
echo "$MERGE_REPO_URL/pull/$pr"
}