mirror of https://github.com/openclaw/openclaw.git
368 lines
12 KiB
Bash
368 lines
12 KiB
Bash
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|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}"
|
|
|
|
if [ ! -s "$file_path" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local count
|
|
count=$(wc -l < "$file_path" | tr -d ' ')
|
|
echo "$label ($count):"
|
|
sed -n "1,${limit}p" "$file_path" | sed 's/^/ - /'
|
|
if [ "$count" -gt "$limit" ]; then
|
|
echo " ... +$((count - limit)) more"
|
|
fi
|
|
}
|
|
|
|
mainline_drift_requires_sync() {
|
|
local prep_head_sha="$1"
|
|
|
|
require_artifact .local/pr-meta.json
|
|
|
|
if ! git cat-file -e "${prep_head_sha}^{commit}" 2>/dev/null; then
|
|
echo "Mainline drift relevance: prep head $prep_head_sha is missing locally; require sync."
|
|
return 0
|
|
fi
|
|
|
|
local delta_file
|
|
local pr_files_file
|
|
local overlap_file
|
|
local critical_file
|
|
delta_file=$(mktemp)
|
|
pr_files_file=$(mktemp)
|
|
overlap_file=$(mktemp)
|
|
critical_file=$(mktemp)
|
|
|
|
git diff --name-only "${prep_head_sha}..origin/main" | sed '/^$/d' | sort -u > "$delta_file"
|
|
jq -r '.files[]?.path // empty' .local/pr-meta.json | sed '/^$/d' | sort -u > "$pr_files_file"
|
|
comm -12 "$delta_file" "$pr_files_file" > "$overlap_file" || true
|
|
|
|
local path
|
|
while IFS= read -r path; do
|
|
[ -n "$path" ] || continue
|
|
if is_mainline_drift_critical_path_for_merge "$path"; then
|
|
printf '%s\n' "$path" >> "$critical_file"
|
|
fi
|
|
done < "$delta_file"
|
|
|
|
local delta_count
|
|
local overlap_count
|
|
local critical_count
|
|
delta_count=$(wc -l < "$delta_file" | tr -d ' ')
|
|
overlap_count=$(wc -l < "$overlap_file" | tr -d ' ')
|
|
critical_count=$(wc -l < "$critical_file" | tr -d ' ')
|
|
|
|
if [ "$delta_count" -eq 0 ]; then
|
|
echo "Mainline drift relevance: unable to enumerate drift files; require sync."
|
|
rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$overlap_count" -gt 0 ] || [ "$critical_count" -gt 0 ]; then
|
|
echo "Mainline drift relevance: sync required before merge."
|
|
print_file_list_with_limit "Mainline files overlapping PR touched files" "$overlap_file"
|
|
print_file_list_with_limit "Mainline files touching merge-critical infrastructure" "$critical_file"
|
|
rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file"
|
|
return 0
|
|
fi
|
|
|
|
echo "Mainline drift relevance: no overlap with PR files and no critical infra drift."
|
|
print_file_list_with_limit "Mainline-only drift files" "$delta_file"
|
|
rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file"
|
|
return 1
|
|
}
|
|
|
|
merge_verify() {
|
|
local pr="$1"
|
|
enter_worktree "$pr" false
|
|
|
|
require_artifact .local/prep.env
|
|
# shellcheck disable=SC1091
|
|
source .local/prep.env
|
|
verify_prep_branch_matches_prepared_head "$pr" "$PREP_HEAD_SHA"
|
|
|
|
local json
|
|
json=$(pr_meta_json "$pr")
|
|
local is_draft
|
|
is_draft=$(printf '%s\n' "$json" | jq -r .isDraft)
|
|
if [ "$is_draft" = "true" ]; then
|
|
echo "PR is draft."
|
|
exit 1
|
|
fi
|
|
local pr_head_sha
|
|
pr_head_sha=$(printf '%s\n' "$json" | jq -r .headRefOid)
|
|
|
|
if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then
|
|
echo "PR head changed after prepare (expected $PREP_HEAD_SHA, got $pr_head_sha)."
|
|
echo "Re-run prepare to refresh prep artifacts and gates: scripts/pr-prepare run $pr"
|
|
echo "Note: docs/changelog-only follow-ups reuse prior gate results automatically."
|
|
|
|
git fetch origin "pull/$pr/head" >/dev/null 2>&1 || true
|
|
if git cat-file -e "${PREP_HEAD_SHA}^{commit}" 2>/dev/null && git cat-file -e "${pr_head_sha}^{commit}" 2>/dev/null; then
|
|
echo "HEAD delta (expected...current):"
|
|
git log --oneline --left-right "${PREP_HEAD_SHA}...${pr_head_sha}" | sed 's/^/ /' || true
|
|
else
|
|
echo "HEAD delta unavailable locally (could not resolve one of the SHAs)."
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
gh pr checks "$pr" --required --watch --fail-fast >.local/merge-checks-watch.log 2>&1 || true
|
|
local checks_json
|
|
local checks_err_file
|
|
checks_err_file=$(mktemp)
|
|
checks_json=$(gh pr checks "$pr" --required --json name,bucket,state 2>"$checks_err_file" || true)
|
|
rm -f "$checks_err_file"
|
|
if [ -z "$checks_json" ]; then
|
|
checks_json='[]'
|
|
fi
|
|
local required_count
|
|
required_count=$(printf '%s\n' "$checks_json" | jq 'length')
|
|
if [ "$required_count" -eq 0 ]; then
|
|
echo "No required checks configured for this PR."
|
|
fi
|
|
printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"'
|
|
|
|
local failed_required
|
|
failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length')
|
|
local pending_required
|
|
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length')
|
|
|
|
if [ "$failed_required" -gt 0 ]; then
|
|
echo "Required checks are failing."
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$pending_required" -gt 0 ]; then
|
|
echo "Required checks are still pending."
|
|
exit 1
|
|
fi
|
|
|
|
git fetch origin main
|
|
git fetch origin "pull/$pr/head:pr-$pr" --force
|
|
if ! git merge-base --is-ancestor origin/main "pr-$pr"; then
|
|
echo "PR branch is behind main."
|
|
if mainline_drift_requires_sync "$PREP_HEAD_SHA"; 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: continuing without prep-head sync because behind-main drift is unrelated."
|
|
fi
|
|
|
|
echo "merge-verify passed for PR #$pr"
|
|
}
|
|
|
|
merge_run() {
|
|
local pr="$1"
|
|
enter_worktree "$pr" false
|
|
|
|
local required
|
|
for required in .local/review.md .local/review.json .local/prep.md .local/prep.env; do
|
|
require_artifact "$required"
|
|
done
|
|
|
|
merge_verify "$pr"
|
|
# shellcheck disable=SC1091
|
|
source .local/prep.env
|
|
|
|
local pr_meta_json
|
|
pr_meta_json=$(gh pr view "$pr" --json number,title,state,isDraft,author)
|
|
local pr_title
|
|
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
|
|
local pr_number
|
|
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
|
|
local contrib
|
|
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
|
|
local is_draft
|
|
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
|
|
if [ "$is_draft" = "true" ]; then
|
|
echo "PR is draft; stop."
|
|
exit 1
|
|
fi
|
|
|
|
local reviewer
|
|
reviewer=$(gh api user --jq .login)
|
|
local reviewer_id
|
|
reviewer_id=$(gh api user --jq .id)
|
|
|
|
local contrib_coauthor_email="${COAUTHOR_EMAIL:-}"
|
|
if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then
|
|
local contrib_id
|
|
contrib_id=$(gh api "users/$contrib" --jq .id)
|
|
contrib_coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
|
|
fi
|
|
|
|
local reviewer_email_candidates=()
|
|
local reviewer_email_candidate
|
|
while IFS= read -r reviewer_email_candidate; do
|
|
[ -n "$reviewer_email_candidate" ] || continue
|
|
reviewer_email_candidates+=("$reviewer_email_candidate")
|
|
done < <(merge_author_email_candidates "$reviewer" "$reviewer_id")
|
|
if [ "${#reviewer_email_candidates[@]}" -eq 0 ]; then
|
|
echo "Unable to resolve a candidate merge author email for reviewer $reviewer"
|
|
exit 1
|
|
fi
|
|
|
|
local reviewer_email="${reviewer_email_candidates[0]}"
|
|
local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com"
|
|
|
|
cat > .local/merge-body.txt <<EOF_BODY
|
|
Merged via squash.
|
|
|
|
Prepared head SHA: $PREP_HEAD_SHA
|
|
Co-authored-by: $contrib <$contrib_coauthor_email>
|
|
Co-authored-by: $reviewer <$reviewer_coauthor_email>
|
|
Reviewed-by: @$reviewer
|
|
EOF_BODY
|
|
|
|
run_merge_with_email() {
|
|
local email="$1"
|
|
local merge_output_file
|
|
merge_output_file=$(mktemp)
|
|
if gh pr merge "$pr" \
|
|
--squash \
|
|
--delete-branch \
|
|
--match-head-commit "$PREP_HEAD_SHA" \
|
|
--author-email "$email" \
|
|
--subject "$pr_title (#$pr_number)" \
|
|
--body-file .local/merge-body.txt \
|
|
>"$merge_output_file" 2>&1
|
|
then
|
|
rm -f "$merge_output_file"
|
|
return 0
|
|
fi
|
|
|
|
MERGE_ERR_MSG=$(cat "$merge_output_file")
|
|
print_relevant_log_excerpt "$merge_output_file"
|
|
rm -f "$merge_output_file"
|
|
return 1
|
|
}
|
|
|
|
local MERGE_ERR_MSG=""
|
|
local selected_merge_author_email="$reviewer_email"
|
|
if ! run_merge_with_email "$selected_merge_author_email"; then
|
|
if is_author_email_merge_error "$MERGE_ERR_MSG" && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then
|
|
selected_merge_author_email="${reviewer_email_candidates[1]}"
|
|
echo "Retrying merge once with fallback author email: $selected_merge_author_email"
|
|
run_merge_with_email "$selected_merge_author_email" || {
|
|
echo "Merge failed after fallback retry."
|
|
exit 1
|
|
}
|
|
else
|
|
echo "Merge failed."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
local state
|
|
state=$(gh pr view "$pr" --json state --jq .state)
|
|
if [ "$state" != "MERGED" ]; then
|
|
echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..."
|
|
local i
|
|
for i in $(seq 1 90); do
|
|
sleep 10
|
|
state=$(gh pr view "$pr" --json state --jq .state)
|
|
if [ "$state" = "MERGED" ]; then
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [ "$state" != "MERGED" ]; then
|
|
echo "PR state is $state after waiting."
|
|
exit 1
|
|
fi
|
|
|
|
local merge_sha
|
|
merge_sha=$(gh pr view "$pr" --json mergeCommit --jq '.mergeCommit.oid')
|
|
if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then
|
|
echo "Merge commit SHA missing."
|
|
exit 1
|
|
fi
|
|
local repo_nwo
|
|
repo_nwo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
|
|
|
local merge_sha_url=""
|
|
if gh api repos/:owner/:repo/commits/"$merge_sha" >/dev/null 2>&1; then
|
|
merge_sha_url="https://github.com/$repo_nwo/commit/$merge_sha"
|
|
else
|
|
echo "Merge commit is not resolvable via repository commit endpoint: $merge_sha"
|
|
exit 1
|
|
fi
|
|
|
|
local prep_sha_url=""
|
|
if gh api repos/:owner/:repo/commits/"$PREP_HEAD_SHA" >/dev/null 2>&1; then
|
|
prep_sha_url="https://github.com/$repo_nwo/commit/$PREP_HEAD_SHA"
|
|
else
|
|
local pr_commit_count
|
|
pr_commit_count=$(gh pr view "$pr" --json commits --jq "[.commits[].oid | select(. == \"$PREP_HEAD_SHA\")] | length")
|
|
if [ "${pr_commit_count:-0}" -gt 0 ]; then
|
|
prep_sha_url="https://github.com/$repo_nwo/pull/$pr/commits/$PREP_HEAD_SHA"
|
|
fi
|
|
fi
|
|
if [ -z "$prep_sha_url" ]; then
|
|
echo "Prepared head SHA is not resolvable in repo commits or PR commit list: $PREP_HEAD_SHA"
|
|
exit 1
|
|
fi
|
|
|
|
local commit_body
|
|
commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message)
|
|
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; }
|
|
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; }
|
|
|
|
local ok=0
|
|
local comment_output=""
|
|
local attempt
|
|
for attempt in 1 2 3; do
|
|
if comment_output=$(gh pr comment "$pr" -F - 2>&1 <<EOF_COMMENT
|
|
Merged via squash.
|
|
|
|
- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)
|
|
- Merge commit: [$merge_sha]($merge_sha_url)
|
|
|
|
Thanks @$contrib!
|
|
EOF_COMMENT
|
|
); then
|
|
ok=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
[ "$ok" -eq 1 ] || { echo "Failed to post PR comment after retries"; exit 1; }
|
|
|
|
local comment_url=""
|
|
comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
|
|
if [ -z "$comment_url" ]; then
|
|
comment_url="unresolved"
|
|
fi
|
|
|
|
local root
|
|
root=$(repo_root)
|
|
cd "$root"
|
|
git worktree remove ".worktrees/pr-$pr" --force
|
|
git branch -D "temp/pr-$pr" 2>/dev/null || true
|
|
git branch -D "pr-$pr" 2>/dev/null || true
|
|
git branch -D "pr-$pr-prep" 2>/dev/null || true
|
|
|
|
local pr_url
|
|
pr_url=$(gh pr view "$pr" --json url --jq .url)
|
|
|
|
echo "merge-run complete for PR #$pr"
|
|
echo "merge commit: $merge_sha"
|
|
echo "merge author email: $selected_merge_author_email"
|
|
echo "completion comment: $comment_url"
|
|
echo "$pr_url"
|
|
}
|