232 lines
7.5 KiB
Bash
Executable File
232 lines
7.5 KiB
Bash
Executable File
#!/bin/bash
|
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
|
source "$SCRIPT_DIR/detect-platform.sh"
|
|
|
|
# Default values
|
|
PR_NUMBER=""
|
|
MERGE_METHOD="squash"
|
|
DELETE_BRANCH=false
|
|
DRY_RUN=false
|
|
EXPECT_HEAD=""
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [OPTIONS]
|
|
|
|
Merge a pull request on the current repository (Gitea or GitHub).
|
|
|
|
Options:
|
|
-n, --number NUMBER PR number to merge (required)
|
|
-m, --method METHOD Merge method: squash only (default: squash)
|
|
-d, --delete-branch Delete the head branch after merge
|
|
--dry-run Run metadata/login preflight without merging
|
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
|
-h, --help Show this help message
|
|
|
|
Examples:
|
|
$(basename "$0") -n 42 # Merge PR #42
|
|
$(basename "$0") -n 42 -m squash # Squash merge
|
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
|
EOF
|
|
exit "${1:-1}"
|
|
}
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-n|--number)
|
|
PR_NUMBER="$2"
|
|
shift 2
|
|
;;
|
|
-m|--method)
|
|
MERGE_METHOD="$2"
|
|
shift 2
|
|
;;
|
|
-d|--delete-branch)
|
|
DELETE_BRANCH=true
|
|
shift
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
--expect-head)
|
|
EXPECT_HEAD="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1" >&2
|
|
usage
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$PR_NUMBER" ]]; then
|
|
echo "Error: PR number is required (-n)" >&2
|
|
usage
|
|
fi
|
|
|
|
if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
|
echo "Error: Invalid PR number '$PR_NUMBER'. PR number must contain digits only." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
|
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
|
exit 1
|
|
fi
|
|
|
|
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
|
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
|
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
|
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
|
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
|
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" != true ]]; then
|
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
|
--purpose merge \
|
|
-B "$HEAD_BRANCH" \
|
|
-R "$HEAD_REPO" \
|
|
--sha "$HEAD_SHA" \
|
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
|
fi
|
|
|
|
PLATFORM=$(detect_platform)
|
|
OWNER=$(get_repo_owner)
|
|
REPO=$(get_repo_name)
|
|
|
|
merge_gitea_with_api() {
|
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
|
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
|
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
head_sha, delete_branch = sys.argv[1:]
|
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
|
if delete_branch == "true":
|
|
payload["delete_branch_after_merge"] = True
|
|
print(json.dumps(payload, separators=(",", ":")))
|
|
PY
|
|
)
|
|
|
|
token=$(get_gitea_token "$host" || true)
|
|
if [[ -n "$token" ]]; then
|
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
|
-X POST \
|
|
-H "User-Agent: curl/8" \
|
|
-H "Authorization: token $token" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$payload" \
|
|
"$api_url" || true)
|
|
if [[ "$raw_code" =~ ^2 ]]; then
|
|
rm -f "$body_file"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
|
if [[ -n "$basic_auth" ]]; then
|
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
|
-X POST \
|
|
-u "$basic_auth" \
|
|
-H "User-Agent: curl/8" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$payload" \
|
|
"$api_url" || true)
|
|
if [[ "$raw_code" =~ ^2 ]]; then
|
|
rm -f "$body_file"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
|
import json
|
|
import sys
|
|
code, path = sys.argv[1], sys.argv[2]
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
raw = handle.read(500)
|
|
data = json.loads(raw) if raw else {}
|
|
message = data.get("message") or data.get("error") or raw or "empty response"
|
|
except Exception:
|
|
try:
|
|
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
|
except Exception:
|
|
message = "unreadable response"
|
|
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
|
PY
|
|
rm -f "$body_file"
|
|
return 1
|
|
}
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
if [[ "$PLATFORM" == "gitea" ]]; then
|
|
HOST=$(get_remote_host) || {
|
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
|
exit 1
|
|
}
|
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
|
if [[ -n "$TEA_LOGIN" ]]; then
|
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
|
else
|
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
|
fi
|
|
else
|
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
case "$PLATFORM" in
|
|
github)
|
|
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
|
"${cmd[@]}"
|
|
;;
|
|
gitea)
|
|
HOST=$(get_remote_host) || {
|
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
|
exit 1
|
|
}
|
|
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
|
# tea cannot express it, so exact-head merges use the authenticated API path.
|
|
merge_gitea_with_api "$HOST"
|
|
;;
|
|
*)
|
|
echo "Error: Could not detect git platform" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "PR #$PR_NUMBER merged successfully"
|