diff --git a/docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md b/docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md new file mode 100644 index 0000000..01540bb --- /dev/null +++ b/docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md @@ -0,0 +1,31 @@ +# Scratchpad: t_301e4e3b pr-merge.sh Gitea empty-uid fallback + +## Task + +Implement a narrow hardening in `packages/mosaic/framework/tools/git/pr-merge.sh` so Gitea merges recover from the known non-interactive `tea pr merge` identity failure: `user does not exist [uid: 0, name: ]`. + +## Constraints + +- Preserve Mosaic policy gates: squash-only, base branch `main`, queue guard unless explicitly skipped. +- Preserve the existing authenticated Gitea API fallback when no tea login exists. +- Do not fallback on arbitrary tea failures. +- Do not expose tokens or credential-bearing remotes. +- Scope is limited to the merge wrapper plus focused test/support/scratchpad files. + +## External issue + +- Gitea issue #520: Harden pr-merge.sh Gitea empty-uid fallback + +## Plan + +1. Add a focused shell regression harness with mocked `tea` and `curl` proving the known empty uid/name failure must fall back to Gitea API. +2. Watch the harness fail on current code. +3. Implement helper functions in `pr-merge.sh` for redacted command display, known failure classification, and authenticated Gitea API merge fallback. +4. Keep unknown `tea` failures blocking by replaying stderr and exiting non-zero. +5. Run syntax, shellcheck if available, focused regression, and repo quality gates before push/PR. + +## Session log + +- 2026-05-22: Read Kanban context, Mosaic global/repo instructions, created isolated branch `fix/t_301e4e3b-pr-merge-gitea-empty-uid`, and opened Gitea issue #520 using the Mosaic issue wrapper/API fallback. +- 2026-05-22: Added regression harness and watched it fail on current behavior with `user does not exist [uid: 0, name: ]`; implemented narrow fallback and verified known-empty-identity fallback, arbitrary tea failure blocking, and no-tea-login API fallback paths. +- 2026-05-22: Validation passed for `bash -n`, `shellcheck -x`, focused shell harness, `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm --filter @mosaicstack/mosaic test`. Full `pnpm test` exposed an out-of-scope gateway DB setup failure (`relation "messages" does not exist`) in `apps/gateway/src/__tests__/cross-user-isolation.test.ts`. diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 73a6e99..cc71438 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -77,6 +77,11 @@ if [[ -z "$PR_NUMBER" ]]; then usage fi +if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: PR number must be numeric." >&2 + exit 1 +fi + if [[ "$MERGE_METHOD" != "squash" ]]; then echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2 exit 1 @@ -104,6 +109,7 @@ 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' @@ -128,9 +134,30 @@ 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" api_url token basic_auth body_file raw_code payload - body_file=$(mktemp) + api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" + 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"}' token=$(get_gitea_token "$host" || true) @@ -166,10 +193,15 @@ import json import sys code, path = sys.argv[1], sys.argv[2] try: - data = json.load(open(path, encoding="utf-8")) - message = data.get("message") or data.get("error") or "unknown API error" + 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: - message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response" + 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" @@ -206,11 +238,25 @@ case "$PLATFORM" in exit 1 } TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}" + if [[ -n "$TEA_LOGIN" ]]; then - tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" + 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" "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" + merge_gitea_with_api "$HOST" fi # Delete branch after merge if requested diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh new file mode 100755 index 0000000..9a3e8a9 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_ROOT="${AGENT_WORK_ROOT:-/home/hermes/agent-work}" +SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$" +MOCK_BIN="$SANDBOX/bin" +REPO_DIR="$SANDBOX/repo" +LOG_FILE="$SANDBOX/mock.log" + +cleanup() { + rm -rf "$SANDBOX" +} +trap cleanup EXIT + +mkdir -p "$MOCK_BIN" "$REPO_DIR" +: > "$LOG_FILE" + +cat > "$MOCK_BIN/tea" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"pr merge"* ]]; then + echo 'user does not exist [uid: 0, name: ]' >&2 + exit 1 +fi +exit 0 +EOF +chmod +x "$MOCK_BIN/tea" + +cat > "$MOCK_BIN/curl" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'curl %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +args=" $* " +if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then + cat <<'JSON' +{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true} +JSON + exit 0 +fi +if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then + cat <<'JSON' +{"merged":true,"message":"mock merge complete"} +JSON + exit 0 +fi +echo "unexpected curl invocation: $*" >&2 +exit 97 +EOF +chmod +x "$MOCK_BIN/curl" + +cd "$REPO_DIR" +git init -q +git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +export PATH="$MOCK_BIN:$PATH" +export PR_MERGE_TEST_LOG="$LOG_FILE" +export GITEA_LOGIN="git.mosaicstack.dev" +export GITEA_TOKEN="redacted-test-token" + +OUTPUT="$SANDBOX/output.log" +if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2 + echo "--- output ---" >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + echo "--- mock log ---" >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi + +if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then + echo "Expected authenticated Gitea merge API endpoint to be called." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi + +if grep -q 'redacted-test-token' "$OUTPUT"; then + echo "Token leaked to pr-merge.sh output." >&2 + exit 1 +fi + +cat > "$MOCK_BIN/tea" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"pr merge"* ]]; then + echo 'tea network timeout' >&2 + exit 2 +fi +exit 0 +EOF +chmod +x "$MOCK_BIN/tea" +: > "$LOG_FILE" +if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected arbitrary tea failure to remain blocking." >&2 + exit 1 +fi +if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then + echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi +if ! grep -q 'tea network timeout' "$OUTPUT"; then + echo "Expected arbitrary tea error to be preserved in output." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + exit 1 +fi + +cat > "$MOCK_BIN/tea" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"login list"* ]]; then + echo '[]' + exit 0 +fi +if [[ "$*" == *"pr merge"* ]]; then + echo 'tea merge should not run without a configured host login' >&2 + exit 99 +fi +exit 0 +EOF +chmod +x "$MOCK_BIN/tea" +unset GITEA_LOGIN +: > "$LOG_FILE" +if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected missing tea login to use authenticated Gitea API fallback." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi +if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then + echo "Expected missing tea login path to call Gitea API merge endpoint." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi + +echo "pr-merge.sh Gitea fallback regression passed"