625 lines
22 KiB
Bash
Executable File
625 lines
22 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] [--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 <<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
|
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
|
-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 --expect-head 0123456789abcdef0123456789abcdef01234567
|
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
|
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)
|
|
if [[ $# -lt 2 ]]; then
|
|
echo "Error: --expect-head requires one full commit SHA." >&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=()
|
|
|
|
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
|
|
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=$(printf '%s\n' "$auth_config" | \
|
|
curl -sS -K - -w '%{http_code}' -o "$response_file" \
|
|
-H "User-Agent: curl/8" "$api_url" || true)
|
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
|
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
|
|
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=$(printf '%s\n' "$auth_config" | \
|
|
curl -sS -K - -w '%{http_code}' -o "$page_file" \
|
|
-H "User-Agent: curl/8" "$api_url" || true)
|
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
|
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 "<unknown>")
|
|
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 "<missing>"
|
|
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 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
|
|
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=$(printf '%s\n' "$auth_config" | \
|
|
curl -sS -K - -w '%{http_code}' -o "$body_file" \
|
|
-X POST -H "User-Agent: curl/8" \
|
|
-H 'Content-Type: application/json' \
|
|
--data-binary "@$payload_file" "$api_url" || true)
|
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
|
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 basic_auth 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 token credential (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR} Basic Auth fallback is allowed only after HTTP 401." >&2
|
|
return 1
|
|
fi
|
|
echo "Token credential received HTTP 401; retrying inspection and merge with configured Basic Auth." >&2
|
|
|
|
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
|
if [[ -n "$basic_auth" ]]; then
|
|
if merge_gitea_api_attempt "$host" basic "$basic_auth"; then
|
|
return 0
|
|
else
|
|
attempt_rc=$?
|
|
fi
|
|
if [[ "$attempt_rc" -eq 75 ]]; then
|
|
return 75
|
|
fi
|
|
fi
|
|
|
|
if [[ -z "$token" && -z "$basic_auth" ]]; then
|
|
echo "Error: No Gitea credential is available for the merge operation." >&2
|
|
else
|
|
echo "Error: Gitea API merge failed for all configured credentials (last HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
|
fi
|
|
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"
|