MOSAIC_GIT_IDENTITY=fargo produced objects attributed to mos-dt-0: every write wrapper resolved its acting principal from tea's login list, which enumerates whatever logins the host happens to hold and knows nothing about which seat is calling. The identity-aware code was present and correct but unreachable on the happy path — it sat on arms that only ran when tea failed. One shared resolver, not twenty patches: resolve_gitea_principal() in detect-platform.sh implements the precedence (explicit --login beats MOSAIC_GIT_IDENTITY / worktree git config mosaic.gitIdentity; the tea login list is the LAST resort), fails loud (nonzero, naming the identity or login and the expected slot path) when the requested principal has no credential, and never prints a token value. gitea_identity_token_slot() is the single source of truth for the slot layout, shared with get_gitea_token, so resolver and token resolution cannot disagree. Call-site conversions (the proving five): pr-review.sh (principal resolved once for every action; the comment action now honors --login), issue-comment.sh, pr-create.sh (identity mode reaches the REST API on the HAPPY path — tea is never consulted, so the login list cannot shadow the identity; --login wins even on the tea-failure fallback arm), issue-create.sh (same), pr-merge.sh (gains --login; --dry-run reports the principal the merge WOULD act as, resolved exactly as the merge resolves it; no cross-principal fallback — an identity-bound 401 is a hard stop). Remaining wrappers are call-site conversions onto the same resolver, measured: write-path issue-assign, issue-close, issue-edit, issue-reopen, milestone-close, milestone-create, pr-close; read-path issue-list, milestone-list, pr-list, pr-view (issue-view mixed). pr-diff, pr-metadata, pr-ci-wait and ci-queue-wait already inherit identity-first resolution via get_gitea_token. Known interaction: on a host with a workstation-GLOBAL mosaic.gitIdentity, this fix activates identity mode for every seat that has not set a local one — correct behavior driven by a wrong configuration (measured: #1282-#1287, six accidental live issues, closed with provenance by fred). Set mosaic.gitIdentity per-worktree, never --global. Tests: test-gitea-principal-resolution.sh (resolver matrix — identity present/absent, --login precedence, env vs git-config, unrecognized-host containment, slot-path-by-path-never-by-value); test-pr-create-identity- first.sh (the load-bearing ordering test: identity arm REACHED on the happy path with tea never invoked, fail-loud BEFORE any write on a missing slot, --login wins, default preserved); test-pr-merge-principal- resolution.sh (dry-run truthfulness, merge credential binding, unknown --login never reaches the provider). All wired into test:framework-shell. Sabotage control: precedence inverted to tea-list-first inside the resolver -> exactly the three new suites redden with the #1280 signatures (identity resolves to the tea-list account; missing slot returns rc=0 with silent fallthrough) while all 11 pre-existing git suites stay green; restored byte-identical (sha256 verified); all 14 green again.
688 lines
26 KiB
Bash
Executable File
688 lines
26 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] [--login <name>]
|
|
#
|
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
|
# mosaic.gitIdentity selects the credential (per-slot token, fail-loud when
|
|
# absent); the shared host credential is the last resort. The merge is
|
|
# performed with the resolved credential only — never a cross-principal
|
|
# fallback (an HTTP 401 from the identity-bound token is a hard stop).
|
|
|
|
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=""
|
|
LOGIN_OVERRIDE=""
|
|
|
|
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
|
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
|
-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
|
|
$(basename "$0") -n 42 --login fred-ms # Merge under the fred-ms tea login
|
|
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
|
|
;;
|
|
--login|-l)
|
|
if [[ $# -lt 2 ]]; then
|
|
echo "Error: --login requires one tea login name." >&2
|
|
exit 1
|
|
fi
|
|
LOGIN_OVERRIDE="$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" && "$BASE_BRANCH" != "next" ]]; then
|
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (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 "<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 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
|
|
|
|
# Identity-first principal resolution (#1280): an explicit --login wins
|
|
# over MOSAIC_GIT_IDENTITY (operator intent beats environment); otherwise
|
|
# get_gitea_token resolves the identity's per-slot token when an identity
|
|
# is requested (fail-loud when absent) and the shared host credential only
|
|
# when no identity is set. No cross-principal fallback: whatever resolves
|
|
# here is the ONLY credential the merge is attempted with.
|
|
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
|
if ! token=$(get_gitea_token_for_login "$LOGIN_OVERRIDE" "$host"); then
|
|
echo "Error: --login '$LOGIN_OVERRIDE' has no host-matched token on host '$host'; refusing to merge under any other principal (#1280 identity-first resolution)." >&2
|
|
return 1
|
|
fi
|
|
else
|
|
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
|
|
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
|
|
}
|
|
# Report the acting principal the merge WOULD use, resolved the same
|
|
# way the real merge resolves it (#1280) — a dry run that names a
|
|
# different principal than the merge would act as is a lie.
|
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$HOST")"; then
|
|
# Fail-loud diagnostic already printed (unresolvable --login or a
|
|
# requested identity with no per-slot token).
|
|
exit 1
|
|
fi
|
|
DRY_PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
|
DRY_PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
|
case "$DRY_PRINCIPAL_MODE" in
|
|
login) DRY_PRINCIPAL_DESC="tea login '$DRY_PRINCIPAL_NAME'" ;;
|
|
identity) DRY_PRINCIPAL_DESC="git identity '$DRY_PRINCIPAL_NAME' (per-slot credential)" ;;
|
|
*) DRY_PRINCIPAL_DESC="default host credential" ;;
|
|
esac
|
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
|
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
|
else
|
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC 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"
|