mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
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.
1190 lines
61 KiB
Bash
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"
|
|
}
|