#!/bin/bash # pr-merge.sh - Merge pull requests on Gitea or GitHub # Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] 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="" CO_AUTHOR_TRAILERS=false ESCALATE_TO="" usage() { cat <&2 exit 1 fi EXPECT_HEAD="$2" shift 2 ;; --co-author-trailers) CO_AUTHOR_TRAILERS=true shift ;; --escalate-to) if [[ $# -lt 2 ]]; then echo "Error: --escalate-to requires one principal name." >&2 exit 1 fi ESCALATE_TO="$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 if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2 exit 1 fi if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then echo "Error: --escalate-to must be one exact principal name." >&2 exit 1 fi if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then echo "Error: --escalate-to is valid only with --co-author-trailers." >&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())')" PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')" PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() 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) write_curl_auth_config() { local mode="$1" credential="$2" printf '%s' "$credential" | python3 -c ' import sys mode = sys.argv[1] credential = sys.stdin.read() if not credential or any(char in credential for char in "\r\n"): raise SystemExit(1) escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"") if mode == "token": print(f"header = \"Authorization: token {escaped}\"") elif mode == "basic": print(f"user = \"{escaped}\"") else: raise SystemExit(1) ' "$mode" } LAST_GITEA_HTTP_CODE="000" LAST_GITEA_ERROR="" MERGE_TEMP_DIRS=() GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}" GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}" GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}" for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2 exit 1 fi done GITEA_CURL_BOUNDS=( --max-filesize "$GITEA_CURL_MAX_BYTES" --max-time "$GITEA_CURL_MAX_TIME" --connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT" ) format_gitea_error_response() { local response_file="$1" python3 - "$response_file" <<'PY' import json import sys with open(sys.argv[1], "rb") as handle: raw = handle.read(65536) try: response = json.loads(raw.decode("utf-8", errors="replace")) except (UnicodeDecodeError, json.JSONDecodeError): message = "non-JSON response omitted" else: if isinstance(response, dict): message = response.get("message") or response.get("error") if not message and response.get("errors") is not None: message = json.dumps(response["errors"], separators=(",", ":")) else: message = None if not message: message = "JSON response contained no error message" message = str(message) if len(message) > 500: message = message[:500] + "..." print(ascii(message)) PY } cleanup_merge_temp_dirs() { local path for path in "${MERGE_TEMP_DIRS[@]}"; do [[ -n "$path" ]] && rm -rf -- "$path" done } trap cleanup_merge_temp_dirs EXIT trap 'exit 130' INT trap 'exit 143' TERM fetch_gitea_pr_head() { local host="$1" auth_mode="$2" credential="$3" work_root="$4" local response_file raw_code api_url auth_config curl_rc response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX") api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}" if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then echo "Error: Could not construct Gitea authentication config; refusing request." >&2 rm -f "$response_file" return 1 fi raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \ -H "User-Agent: curl/8" "$api_url" <<<"$auth_config") curl_rc=$? LAST_GITEA_HTTP_CODE="${raw_code:-000}" if [[ "$curl_rc" -ne 0 ]]; then LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" rm -f "$response_file" return 1 fi if [[ ! "$raw_code" =~ ^2 ]]; then LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file") rm -f "$response_file" return 1 fi if ! python3 - "$response_file" <<'PY' import json import re import sys with open(sys.argv[1], encoding="utf-8") as handle: pull = json.load(handle) head = pull.get("head") if isinstance(pull, dict) else None sha = str(head.get("sha") or "") if isinstance(head, dict) else "" if not re.fullmatch(r"[0-9a-fA-F]{40}", sha): raise SystemExit(1) print(sha) PY then echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2 rm -f "$response_file" return 1 fi rm -f "$response_file" } fetch_gitea_pr_commits() { local host="$1" auth_mode="$2" credential="$3" work_root="$4" local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc mkdir -p "$work_root" if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then echo "Error: Could not construct Gitea authentication config; refusing request." >&2 return 1 fi combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX") printf '[]' > "$combined_file" page=1 while true; do page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX") api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}" raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \ -H "User-Agent: curl/8" "$api_url" <<<"$auth_config") curl_rc=$? LAST_GITEA_HTTP_CODE="${raw_code:-000}" if [[ "$curl_rc" -ne 0 ]]; then LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" rm -f "$page_file" "$combined_file" return 1 fi if [[ ! "$raw_code" =~ ^2 ]]; then LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file") rm -f "$page_file" "$combined_file" return 1 fi if ! page_count=$(python3 - "$page_file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: page = json.load(handle) if not isinstance(page, list): raise SystemExit(1) print(len(page)) PY ); then echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2 rm -f "$page_file" "$combined_file" return 1 fi merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX") if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: combined = json.load(handle) with open(sys.argv[2], encoding="utf-8") as handle: page = json.load(handle) json.dump(combined + page, sys.stdout, separators=(",", ":")) PY then echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2 rm -f "$page_file" "$combined_file" "$merged_file" return 1 fi mv "$merged_file" "$combined_file" rm -f "$page_file" if [[ "$page_count" -lt 50 ]]; then break fi page=$((page + 1)) if [[ "$page" -gt 1000 ]]; then echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2 rm -f "$combined_file" return 1 fi done cat "$combined_file" rm -f "$combined_file" } # LIMITATION: author.login resolution proves the commit address maps to a registered account. # It does NOT prove the named principal authored the commit — git author metadata is self-asserted. # This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted. build_coauthor_message_fields() { local commits_file="$1" context_file="$2" head_file="$3" python3 - "$commits_file" "$context_file" "$head_file" <<'PY' import json import re import sys commits_path, context_path, head_path = sys.argv[1:] with open(commits_path, encoding="utf-8") as handle: commits = json.load(handle) head_sha = open(head_path, encoding="utf-8").read().strip() context_parts = open(context_path, "rb").read().split(b"\0") if len(context_parts) != 4 or context_parts[-1] != b"": raise SystemExit(1) poster, title, principal = (part.decode("utf-8") for part in context_parts[:3]) if not isinstance(commits, list) or not commits: print( f"BLOCK: provider returned no PR commits; author identity is unmeasurable. " f"Refusing merge; escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) if not poster: print( f"BLOCK: PR poster login is empty; refusing merge; " f"escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha): print( f"BLOCK: inspected PR head SHA is invalid; refusing merge; " f"escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) seen = set() trailers = [] head_seen = False for item in commits: if not isinstance(item, dict): print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr) raise SystemExit(75) sha = str(item.get("sha") or "") if sha == head_sha: head_seen = True commit = item.get("commit") if isinstance(item.get("commit"), dict) else {} commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {} email = str(commit_author.get("email") or "").strip() provider_author = item.get("author") if isinstance(item.get("author"), dict) else {} login = str(provider_author.get("login") or "").strip() if not login: diagnostic_email = email or "" print( f"BLOCK: commit {sha!r} has author.login=NULL while " f"commit.author.email={diagnostic_email!r}; refusing merge; " f"escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) if ( not email.isascii() or not email.isprintable() or not re.fullmatch(r"[A-Za-z0-9_.-]+", login) or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email) ): print( f"BLOCK: commit {sha!r} has unusable linked identity " f"author.login={login!r}, commit.author.email={email!r}; refusing merge; " f"escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) if login == poster or login in seen: continue seen.add(login) trailers.append(f"Co-authored-by: {login} <{email}>") if not head_seen: print( f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; " f"escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) if not trailers: print("{}") raise SystemExit(0) if not title: print( f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title): print( f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; " f"escalate to named principal '{principal}'.", file=sys.stderr, ) raise SystemExit(75) print(json.dumps({ "MergeTitleField": title, "MergeMessageField": "\n".join(trailers), }, separators=(",", ":"))) PY } merge_gitea_api_attempt() { local host="$1" auth_mode="$2" credential="$3" local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc LAST_GITEA_HTTP_CODE="000" LAST_GITEA_ERROR="" api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" mkdir -p "$work_root" attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX") chmod 0700 "$attempt_dir" MERGE_TEMP_DIRS+=("$attempt_dir") body_file=$(mktemp "$attempt_dir/api-response.XXXXXX") fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX") payload_file=$(mktemp "$attempt_dir/payload.XXXXXX") printf '{}' > "$fields_file" if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX") context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX") head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX") printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file" if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then : else attempt_rc=$? rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" return "$attempt_rc" fi if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2 rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" return 75 fi if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then : else attempt_rc=$? rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" return "$attempt_rc" fi if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then : else attempt_rc=$? rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" return "$attempt_rc" fi rm -f "$commits_file" "$context_file" "$head_file" fi if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: fields = json.load(handle) head_sha, delete_branch = sys.argv[2:] payload = {"Do": "squash", "head_commit_id": head_sha} if delete_branch == "true": payload["delete_branch_after_merge"] = True payload.update(fields) allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"} if payload.get("Do") != "squash" or set(payload) - allowed: raise SystemExit(1) print(json.dumps(payload, separators=(",", ":"))) PY then rm -f "$body_file" "$fields_file" "$payload_file" return 1 fi rm -f "$fields_file" if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then echo "Error: Could not construct Gitea authentication config; refusing request." >&2 rm -f "$body_file" "$payload_file" return 1 fi raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \ -X POST -H "User-Agent: curl/8" \ -H 'Content-Type: application/json' \ --data-binary "@$payload_file" "$api_url" <<<"$auth_config") curl_rc=$? LAST_GITEA_HTTP_CODE="${raw_code:-000}" if [[ "$curl_rc" -ne 0 ]]; then LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" rm -f "$body_file" "$payload_file" rm -rf -- "$attempt_dir" return 1 fi if [[ ! "$raw_code" =~ ^2 ]]; then LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file") fi rm -f "$body_file" "$payload_file" rm -rf -- "$attempt_dir" [[ "$raw_code" =~ ^2 ]] } merge_gitea_with_api() { local host="$1" token attempt_rc if ! token=$(get_gitea_token "$host"); then echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2 return 1 fi if [[ -z "$token" ]]; then echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2 return 1 fi if merge_gitea_api_attempt "$host" token "$token"; then return 0 else attempt_rc=$? fi if [[ "$attempt_rc" -eq 75 ]]; then return 75 fi if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2 return 1 fi echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2 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 } if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)." else echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (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) if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2 exit 1 fi 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 every Gitea merge uses 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"