#!/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 usage() { cat <&2 usage ;; esac done if [[ -z "$PR_NUMBER" ]]; then echo "Error: PR number is required (-n)" >&2 usage fi if [[ "$MERGE_METHOD" != "squash" ]]; then echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2 exit 1 fi BASE_BRANCH="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER" | 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" local api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" local token body_file payload token=$(get_gitea_token "$host" || true) if [[ -z "$token" ]]; then echo "Error: No Gitea API token available for authenticated merge fallback on $host." >&2 return 1 fi 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"}' if curl -fsS \ -X POST \ -H "Authorization: token $token" \ -H 'Content-Type: application/json' \ -d "$payload" \ "$api_url" > "$body_file"; then rm -f "$body_file" return 0 fi python3 - "$body_file" <<'PY' >&2 import json import sys path = sys.argv[1] 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: with open(path, encoding="utf-8", errors="replace") as handle: message = handle.read(500) or "empty response" except Exception: message = "unreadable response" print(f"Error: Gitea API merge fallback failed: {message}") PY rm -f "$body_file" return 1 } case "$PLATFORM" in github) CMD="gh pr merge $PR_NUMBER --squash" [[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch" eval "$CMD" ;; 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)}" # 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 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 ;; *) echo "Error: Could not detect git platform" >&2 exit 1 ;; esac echo "PR #$PR_NUMBER merged successfully"