diff --git a/docs/scratchpads/mvp-20260312.md b/docs/scratchpads/mvp-20260312.md index 9e87550..420ee5f 100644 --- a/docs/scratchpads/mvp-20260312.md +++ b/docs/scratchpads/mvp-20260312.md @@ -653,3 +653,9 @@ Independent security review surfaced three high-impact and four medium findings; 2. After merge, kickoff M3-01 (DTOs) on `feat/federation-m3-types` with sonnet subagent in worktree 3. Once M3-01 lands, fan out: M3-02 (harness) || M3-03 (AuthGuard) → M3-04 (ScopeService) || M3-08 (FederationClient) 4. Re-converge at M3-10 (Integration) → M3-11 (E2E) + +### Session 24 — 2026-05-22 — Gitea PR metadata wrapper hardening + +- Fixed `packages/mosaic/framework/tools/git/pr-metadata.sh` Gitea path to fail closed on non-2xx API responses instead of normalizing API error JSON into null/empty PR metadata. +- Added token fallback behavior: try explicit `GITEA_TOKEN`, Mosaic credential-loader token, then matching `~/.git-credentials` HTTPS token; this handles stale host-scoped credential-loader entries while preserving existing credential sources. +- Confirmed real U-Connect Gitea PRs #1905 and #1908 now return `number`, `headRefName`, `baseRefName`, `state`, `author`, `url`, and `mergeable` correctly, restoring `pr-merge.sh` base branch detection. diff --git a/packages/mosaic/framework/tools/git/pr-metadata.sh b/packages/mosaic/framework/tools/git/pr-metadata.sh index 82344a9..24b1661 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -69,32 +69,81 @@ elif [[ "$PLATFORM" == "gitea" ]]; then API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}" - GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true) + declare -a TOKEN_CANDIDATES=() + add_token_candidate() { + local candidate="$1" + [[ -z "$candidate" ]] && return 0 + local existing + for existing in "${TOKEN_CANDIDATES[@]:-}"; do + [[ "$existing" == "$candidate" ]] && return 0 + done + TOKEN_CANDIDATES+=("$candidate") + } - if [[ -n "$GITEA_API_TOKEN" ]]; then - RAW=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$API_URL") - else - RAW=$(curl -sS "$API_URL") + add_token_candidate "${GITEA_TOKEN:-}" + add_token_candidate "$(get_gitea_token "$HOST" || true)" + + # Git HTTPS credentials often contain a valid Gitea API token even when a + # Mosaic credential-source entry is stale. Try them as a fallback before + # falling back to an unauthenticated request. + CREDS_FILE="$HOME/.git-credentials" + if [[ -f "$CREDS_FILE" ]]; then + while IFS= read -r credential_token; do + add_token_candidate "$credential_token" + done < <(grep -F "$HOST" "$CREDS_FILE" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p') + fi + + RAW="" + HTTP_STATUS="" + if [[ ${#TOKEN_CANDIDATES[@]} -gt 0 ]]; then + for GITEA_API_TOKEN in "${TOKEN_CANDIDATES[@]}"; do + RESPONSE_FILE=$(mktemp) + HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -H "Authorization: token $GITEA_API_TOKEN" "$API_URL" || true) + RAW=$(cat "$RESPONSE_FILE") + rm -f "$RESPONSE_FILE" + [[ "$HTTP_STATUS" =~ ^2 ]] && break + done + fi + + if [[ ! "$HTTP_STATUS" =~ ^2 ]]; then + RESPONSE_FILE=$(mktemp) + HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' "$API_URL" || true) + RAW=$(cat "$RESPONSE_FILE") + rm -f "$RESPONSE_FILE" + fi + + if [[ ! "$HTTP_STATUS" =~ ^2 ]]; then + ERROR_MESSAGE=$(printf '%s' "$RAW" | python3 -c 'import json, sys +try: + data=json.load(sys.stdin) +except Exception: + data={} +print(data.get("message") or data.get("error") or "unknown error")') + echo "Error: failed to fetch Gitea PR #$PR_NUMBER from $HOST/$OWNER/$REPO (HTTP $HTTP_STATUS): $ERROR_MESSAGE" >&2 + exit 1 fi # Normalize Gitea response to match our expected schema METADATA=$(echo "$RAW" | python3 -c " import json, sys data = json.load(sys.stdin) +head = data.get('head') or {} +base = data.get('base') or {} +user = data.get('user') or {} normalized = { - 'number': data.get('number'), + 'number': data.get('number') or data.get('index'), 'title': data.get('title'), 'body': data.get('body', ''), 'state': data.get('state'), - 'author': data.get('user', {}).get('login', ''), - 'headRefName': data.get('head', {}).get('ref', ''), - 'baseRefName': data.get('base', {}).get('ref', ''), + 'author': user.get('login', ''), + 'headRefName': head.get('ref') or head.get('label', '').split(':')[-1], + 'baseRefName': base.get('ref') or base.get('label', '').split(':')[-1], 'labels': [l.get('name', '') for l in data.get('labels', [])], 'assignees': [a.get('login', '') for a in data.get('assignees', [])], 'milestone': data.get('milestone', {}).get('title', '') if data.get('milestone') else '', 'createdAt': data.get('created_at', ''), 'updatedAt': data.get('updated_at', ''), - 'url': data.get('html_url', ''), + 'url': data.get('html_url') or data.get('url', ''), 'isDraft': data.get('draft', False), 'mergeable': data.get('mergeable'), 'diffUrl': data.get('diff_url', ''),