#!/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 EXPECT_HEAD="" usage() { cat <&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 [[ "$SKIP_QUEUE_GUARD" != 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"