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 ad8c318..8e472f9 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -2,9 +2,10 @@ # pr-merge.sh - Merge pull requests on Gitea or GitHub # Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] -set -e +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 @@ -92,6 +93,101 @@ PLATFORM=$(detect_platform) OWNER=$(get_repo_owner) 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' +import json +import os +import sys + +host = sys.argv[1] +try: + logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]")) +except Exception: + raise SystemExit(1) + +for login in logins if isinstance(logins, list) else []: + url = str(login.get("url") or login.get("URL") or "") + name = str(login.get("name") or login.get("Name") or "") + if url.rstrip("/").endswith(host) and name: + print(name) + raise SystemExit(0) + +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" + local api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" + local token body_file payload + + token=$(get_gitea_token "$host" || true) + if [[ -z "$token" ]]; then + echo "Error: No Gitea API token available for authenticated merge fallback on $host." >&2 + return 1 + fi + + 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"}' + + if curl -fsS \ + -X POST \ + -H "Authorization: token $token" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$api_url" > "$body_file"; then + rm -f "$body_file" + return 0 + fi + + python3 - "$body_file" <<'PY' >&2 +import json +import sys + +path = sys.argv[1] +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: + with open(path, encoding="utf-8", errors="replace") as handle: + message = handle.read(500) or "empty response" + except Exception: + message = "unreadable response" + +print(f"Error: Gitea API merge fallback failed: {message}") +PY + rm -f "$body_file" + return 1 +} + case "$PLATFORM" in github) CMD="gh pr merge $PR_NUMBER --squash" @@ -99,14 +195,36 @@ case "$PLATFORM" in eval "$CMD" ;; gitea) - CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}" + HOST=$(get_remote_host) || { + echo "Error: Cannot determine host from origin remote URL" >&2 + exit 1 + } + TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}" # Delete branch after merge if requested if [[ "$DELETE_BRANCH" == true ]]; then echo "Note: Branch deletion after merge may need to be done separately with tea" >&2 fi - eval "$CMD" + if [[ -n "$TEA_LOGIN" ]]; then + 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" + fi ;; *) echo "Error: Could not detect git platform" >&2 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"