274 lines
8.3 KiB
Bash
Executable File
274 lines
8.3 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] [--skip-queue-guard]
|
|
|
|
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
|
|
SKIP_QUEUE_GUARD=false
|
|
DRY_RUN=false
|
|
|
|
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
|
|
--skip-queue-guard Skip CI queue guard wait before merge
|
|
--dry-run Run metadata/login preflight without merging
|
|
-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
|
|
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
|
EOF
|
|
exit 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
|
|
;;
|
|
--skip-queue-guard)
|
|
SKIP_QUEUE_GUARD=true
|
|
shift
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
SKIP_QUEUE_GUARD=true
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
;;
|
|
*)
|
|
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: PR number must be numeric." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&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())')"
|
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
|
--purpose merge \
|
|
-B "$BASE_BRANCH" \
|
|
-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)
|
|
|
|
find_tea_login_for_host() {
|
|
local host="$1"
|
|
local logins_json
|
|
|
|
command -v tea >/dev/null 2>&1 || return 1
|
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
|
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
host = sys.argv[1]
|
|
try:
|
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
|
except Exception:
|
|
raise SystemExit(1)
|
|
|
|
for login in logins if isinstance(logins, list) else []:
|
|
url = str(login.get("url") or login.get("URL") or "")
|
|
name = str(login.get("name") or login.get("Name") or "")
|
|
if url.rstrip("/").endswith(host) and name:
|
|
print(name)
|
|
raise SystemExit(0)
|
|
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
is_known_tea_empty_identity_failure() {
|
|
local error_file="$1"
|
|
|
|
python3 - "$error_file" <<'PY'
|
|
import re
|
|
import sys
|
|
|
|
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
|
error = handle.read()
|
|
|
|
known_empty_identity = re.search(
|
|
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
|
error,
|
|
flags=re.IGNORECASE | re.DOTALL,
|
|
)
|
|
raise SystemExit(0 if known_empty_identity else 1)
|
|
PY
|
|
}
|
|
|
|
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/hermes/agent-work}"
|
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-api-response.XXXXXX")
|
|
payload='{"Do":"squash"}'
|
|
|
|
token=$(get_gitea_token "$host" || true)
|
|
if [[ -n "$token" ]]; then
|
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
|
-X POST \
|
|
-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 '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="${GITEA_LOGIN:-$(find_tea_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)
|
|
GH_ARGS=(pr merge "$PR_NUMBER" --squash)
|
|
[[ "$DELETE_BRANCH" == true ]] && GH_ARGS+=(--delete-branch)
|
|
gh "${GH_ARGS[@]}"
|
|
;;
|
|
gitea)
|
|
HOST=$(get_remote_host) || {
|
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
|
exit 1
|
|
}
|
|
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
|
|
|
if [[ -n "$TEA_LOGIN" ]]; then
|
|
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
|
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-tea-error.XXXXXX")
|
|
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
|
rm -f "$TEA_ERROR_FILE"
|
|
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
|
cat "$TEA_ERROR_FILE" >&2
|
|
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
|
rm -f "$TEA_ERROR_FILE"
|
|
merge_gitea_with_api "$HOST"
|
|
else
|
|
cat "$TEA_ERROR_FILE" >&2
|
|
rm -f "$TEA_ERROR_FILE"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
|
merge_gitea_with_api "$HOST"
|
|
fi
|
|
|
|
# Delete branch after merge if requested
|
|
if [[ "$DELETE_BRANCH" == true ]]; then
|
|
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
|
fi
|
|
;;
|
|
*)
|
|
echo "Error: Could not detect git platform" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "PR #$PR_NUMBER merged successfully"
|