diff --git a/docs/TASKS.md b/docs/TASKS.md index 49d9d03..4d5824b 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -30,6 +30,7 @@ These are MVP-level checks that don't belong to any single workstream. Updated b | MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit | | MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` | | MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup | +| T-A292E96F | in-progress | Fix Mosaic Gitea PR metadata/login wrapper regression for U-Connect merge preflight | Kanban `t_a292e96f`; branch `fix/t-a292e96f-gitea-pr-metadata`; scratchpad `docs/scratchpads/t-a292e96f-gitea-pr-metadata.md` | ## Pointer to Active Workstream diff --git a/docs/scratchpads/t-a292e96f-gitea-pr-metadata.md b/docs/scratchpads/t-a292e96f-gitea-pr-metadata.md new file mode 100644 index 0000000..6761acb --- /dev/null +++ b/docs/scratchpads/t-a292e96f-gitea-pr-metadata.md @@ -0,0 +1,53 @@ +# t_a292e96f — Gitea PR metadata wrapper fix + +## Objective + +Repair Mosaic git wrappers so Gitea PR metadata and merge preflight work for U-Connect PRs on `git.uscllc.com` without selecting the unrelated `git.mosaicstack.dev` tea login. + +## Findings + +- Reproduced the failure from `/src/uconnect-worktrees/t_39ce717c-authentik-smoke-gate` with the current `pr-metadata.sh`: + - PR #1905 returned JSON with `number=null`, `baseRefName=""`, `headRefName=""`. + - PR #1908 returned JSON with `number=null`, `baseRefName=""`, `headRefName=""`. +- Root cause: the wrapper treated HTTP/API error payloads as PR payloads and normalized missing fields to empty strings. +- The credential loader can return a non-working `git.uscllc.com` API token in this environment, while host-specific `~/.git-credentials` basic auth succeeds. The wrapper now falls back by host before normalization. +- `tea login list` has only `git.mosaicstack.dev` configured here; `pr-merge.sh` previously forced `--login mosaicstack`, which is invalid for `git.uscllc.com` and caused `Login name mosaicstack does not exist`. + +## Changes + +- `packages/mosaic/framework/tools/git/detect-platform.sh` + - Added `get_gitea_basic_auth ` to retrieve host-specific HTTPS credentials from `~/.git-credentials` without printing secrets. +- `packages/mosaic/framework/tools/git/pr-metadata.sh` + - Uses strict bash mode. + - Checks Gitea HTTP status and fails nonzero on API errors/non-JSON instead of emitting empty branch fields. + - Falls back from token auth to host-specific basic auth. + - Normalizes standard `head.ref`/`base.ref` and fallback branch fields. + - Requires non-empty `headRefName` and `baseRefName`. + - Preserves GitHub `gh pr view` behavior. +- `packages/mosaic/framework/tools/git/pr-merge.sh` + - Reads metadata once for base-branch policy preflight. + - Selects a `tea` login only when its configured URL matches the repo host. + - Falls back to authenticated Gitea merge API when no matching `tea` login exists, avoiding the wrong `mosaicstack` login for USC repos. + - Keeps squash-only and main-only merge policy. +- `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` + - Added fixture-based regression harness for standard Gitea fields, fallback branch fields, `refs/pull//head` plus `head.label` normalization, and API error payloads. + +## Documentation / changelog note + +This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/TASKS.md` carry the task-level change record for this wrapper fix. + +## Verification log + +- Red regression check: copied the new `test-pr-metadata-gitea.sh` harness next to `origin/main` wrapper scripts and ran it with `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-red`; it failed as expected with `headRefName=''` and `baseRefName=''` on the fixture API-error path. +- `bash -n packages/mosaic/framework/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh,test-pr-metadata-gitea.sh}`: passed. +- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh,test-pr-metadata-gitea.sh}`: passed. +- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`: passed; verifies standard Gitea fields, fallback branch fields, `refs/pull//head` label normalization, and nonzero API-error handling. +- Installed wrapper parity: `/home/hermes/.config/mosaic/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh}` byte-match the PR source copies after validation, so active U-Connect wrapper invocations use the same fix while source PR review runs. +- Live sanitized U-Connect metadata from `/src/uconnect` with `MOSAIC_CREDENTIALS_FILE=/src/jarvis-brain/credentials.json`: + - PR #1905: `number=1905`, `baseRefName=main`, `headRefName=edith/t_39ce717c-authentik-smoke-gate`, `state=open`, `host=git.uscllc.com`. + - PR #1908: `number=1908`, `baseRefName=main`, `headRefName=fix/t_23fa9e1d-portal-health-backend`, `state=closed`, `host=git.uscllc.com`. +- Merge preflight dry runs from installed wrappers: + - PR #1905: `Dry run: would merge PR #1905 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).` + - PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).` +- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`, head `006d3f375ee9ed9e8e5ce301105d14c4e22f93e2`. +- CI: PR pipeline #1096 and manual rerun #1097 failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index c53e0af..72c77c4 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -103,16 +103,28 @@ get_gitea_token() { if [[ -f "$cred_loader" ]]; then local token token=$( + # shellcheck source=/dev/null source "$cred_loader" + # Host-specific wrapper resolution must not inherit caller/global GITEA_*. + # load_credentials intentionally preserves existing env vars for interactive use, + # but metadata/merge wrappers need credentials matching the remote host. + unset GITEA_TOKEN GITEA_URL case "$host" in git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;; git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;; *) + local matched=false for svc in gitea-mosaicstack gitea-usc; do - load_credentials "$svc" 2>/dev/null || continue - [[ "${GITEA_URL:-}" == *"$host"* ]] && break unset GITEA_TOKEN GITEA_URL + load_credentials "$svc" 2>/dev/null || continue + if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then + matched=true + break + fi done + if [[ "$matched" != true ]]; then + unset GITEA_TOKEN GITEA_URL + fi ;; esac echo "${GITEA_TOKEN:-}" @@ -123,10 +135,12 @@ get_gitea_token() { fi fi - # 2. GITEA_TOKEN env var (may be set by caller) + # 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host) if [[ -n "${GITEA_TOKEN:-}" ]]; then - echo "$GITEA_TOKEN" - return 0 + if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then + echo "$GITEA_TOKEN" + return 0 + fi fi # 3. ~/.git-credentials file @@ -143,6 +157,37 @@ get_gitea_token() { return 1 } +# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials. +# Prints "username:password" for direct curl -u consumption. Callers must not log it. +get_gitea_basic_auth() { + local host="$1" + local creds="$HOME/.git-credentials" + if [[ ! -f "$creds" ]]; then + return 1 + fi + + python3 - "$host" "$creds" <<'PY' +import sys +from pathlib import Path +from urllib.parse import unquote, urlparse + +host = sys.argv[1] +creds = Path(sys.argv[2]) + +for line in creds.read_text(encoding="utf-8").splitlines(): + parsed = urlparse(line.strip()) + if parsed.hostname != host: + continue + username = unquote(parsed.username or "") + password = unquote(parsed.password or "") + if username and password: + print(f"{username}:{password}") + raise SystemExit(0) + +raise SystemExit(1) +PY +} + # If script is run directly (not sourced), output the platform if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then detect_platform diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index ad8c318..73a6e99 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 @@ -12,6 +13,7 @@ PR_NUMBER="" MERGE_METHOD="squash" DELETE_BRANCH=false SKIP_QUEUE_GUARD=false +DRY_RUN=false usage() { cat <&2 exit 1 @@ -92,21 +101,122 @@ 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 +} + +merge_gitea_with_api() { + local host="$1" api_url token basic_auth body_file raw_code payload + body_file=$(mktemp) + payload='{"Do":"squash"}' + + token=$(get_gitea_token "$host" || true) + if [[ -n "$token" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ + -X POST \ + -H "Authorization: token $token" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$api_url" || true) + if [[ "$raw_code" =~ ^2 ]]; then + rm -f "$body_file" + return 0 + fi + fi + + basic_auth=$(get_gitea_basic_auth "$host" || true) + if [[ -n "$basic_auth" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ + -X POST \ + -u "$basic_auth" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$api_url" || true) + if [[ "$raw_code" =~ ^2 ]]; then + rm -f "$body_file" + return 0 + fi + fi + + python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2 +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" +except Exception: + message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response" +print(f"Error: Gitea API merge failed with HTTP {code}: {message}") +PY + rm -f "$body_file" + 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 + } + TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}" + if [[ -n "$TEA_LOGIN" ]]; then + echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)." + else + echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (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) - CMD="gh pr merge $PR_NUMBER --squash" - [[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch" - eval "$CMD" + GH_ARGS=(pr merge "$PR_NUMBER" --squash) + [[ "$DELETE_BRANCH" == true ]] && GH_ARGS+=(--delete-branch) + gh "${GH_ARGS[@]}" ;; 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)}" + if [[ -n "$TEA_LOGIN" ]]; then + tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" + 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" + fi # 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" ;; *) echo "Error: Could not detect git platform" >&2 diff --git a/packages/mosaic/framework/tools/git/pr-metadata.sh b/packages/mosaic/framework/tools/git/pr-metadata.sh index 82344a9..40fc0d2 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -2,9 +2,10 @@ # pr-metadata.sh - Get PR metadata as JSON on GitHub or Gitea # Usage: pr-metadata.sh -n [-o ] -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" # Parse arguments @@ -31,7 +32,7 @@ while [[ $# -gt 0 ]]; do exit 0 ;; *) - echo "Unknown option: $1" + echo "Unknown option: $1" >&2 exit 1 ;; esac @@ -42,56 +43,168 @@ if [[ -z "$PR_NUMBER" ]]; then exit 1 fi +write_metadata() { + local metadata="$1" + if [[ -n "$OUTPUT_FILE" ]]; then + printf '%s\n' "$metadata" > "$OUTPUT_FILE" + else + printf '%s\n' "$metadata" + fi +} + +curl_gitea_pull() { + local api_url="$1" + local token basic_auth raw_code body_file http_code + body_file=$(mktemp) + + token=$(get_gitea_token "$HOST" || true) + if [[ -n "$token" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "Authorization: token $token" "$api_url" || true) + if [[ "$raw_code" =~ ^2 ]]; then + cat "$body_file" + rm -f "$body_file" + return 0 + fi + http_code="$raw_code" + fi + + basic_auth=$(get_gitea_basic_auth "$HOST" || true) + if [[ -n "$basic_auth" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" "$api_url" || true) + if [[ "$raw_code" =~ ^2 ]]; then + cat "$body_file" + rm -f "$body_file" + return 0 + fi + http_code="$raw_code" + fi + + if [[ -z "${http_code:-}" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" "$api_url" || true) + http_code="$raw_code" + fi + + python3 - "$http_code" "$body_file" <<'PY' >&2 +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" +except Exception: + message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response" +print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}") +PY + rm -f "$body_file" + return 1 +} + detect_platform > /dev/null if [[ "$PLATFORM" == "github" ]]; then METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft) - - if [[ -n "$OUTPUT_FILE" ]]; then - echo "$METADATA" > "$OUTPUT_FILE" - else - echo "$METADATA" - fi + write_metadata "$METADATA" elif [[ "$PLATFORM" == "gitea" ]]; then OWNER=$(get_repo_owner) REPO=$(get_repo_name) - REMOTE_URL=$(git remote get-url origin 2>/dev/null) - - # Extract host from remote URL - if [[ "$REMOTE_URL" == https://* ]]; then - HOST=$(echo "$REMOTE_URL" | sed -E 's|https://([^/]+)/.*|\1|') - elif [[ "$REMOTE_URL" == git@* ]]; then - HOST=$(echo "$REMOTE_URL" | sed -E 's|git@([^:]+):.*|\1|') - else - echo "Error: Cannot determine host from remote URL" >&2 + HOST=$(get_remote_host) || { + echo "Error: Cannot determine host from origin remote URL" >&2 exit 1 - fi + } API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}" - - GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true) - - if [[ -n "$GITEA_API_TOKEN" ]]; then - RAW=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$API_URL") + if [[ -n "${MOSAIC_GITEA_PR_METADATA_RAW_FILE:-}" ]]; then + RAW=$(cat "$MOSAIC_GITEA_PR_METADATA_RAW_FILE") else - RAW=$(curl -sS "$API_URL") + RAW=$(curl_gitea_pull "$API_URL") fi - # Normalize Gitea response to match our expected schema - METADATA=$(echo "$RAW" | python3 -c " -import json, sys -data = json.load(sys.stdin) + # Normalize Gitea response to match GitHub's expected metadata schema. + METADATA=$(printf '%s' "$RAW" | python3 -c " +import json +import sys + +def first_non_empty(*values): + for value in values: + if value is None: + continue + if isinstance(value, str): + value = value.strip() + if value: + return value + return '' + +def nested(data, *keys): + current = data + for key in keys: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + +try: + data = json.load(sys.stdin) +except json.JSONDecodeError as exc: + print(f'Error: Gitea API returned non-JSON response: {exc}', file=sys.stderr) + sys.exit(1) + +if not isinstance(data, dict): + print('Error: Gitea API returned an unexpected non-object response', file=sys.stderr) + sys.exit(1) + +if data.get('message') and not data.get('number'): + print(f\"Error: Gitea API error: {data.get('message')}\", file=sys.stderr) + sys.exit(1) + +head_ref = first_non_empty( + nested(data, 'head', 'ref'), + nested(data, 'head', 'name'), + nested(data, 'head', 'branch'), + data.get('head_branch'), + data.get('head_ref'), + nested(data, 'head', 'label'), + data.get('head_label'), +) +if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'): + head_ref = first_non_empty( + nested(data, 'head', 'label'), + data.get('head_label'), + nested(data, 'head', 'name'), + nested(data, 'head', 'branch'), + data.get('head_branch'), + data.get('head_ref'), + head_ref, + ) +base_ref = first_non_empty( + nested(data, 'base', 'ref'), + nested(data, 'base', 'name'), + nested(data, 'base', 'branch'), + data.get('base_branch'), + data.get('base_ref'), + data.get('base_label'), +) + +if not head_ref or not base_ref: + available = ', '.join(sorted(data.keys())) + print( + 'Error: Unable to resolve non-empty Gitea PR head/base refs ' + f'(headRefName={head_ref!r}, baseRefName={base_ref!r}; keys={available})', + file=sys.stderr, + ) + sys.exit(1) + normalized = { 'number': data.get('number'), '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', ''), - '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 '', + 'author': nested(data, 'user', 'login') or '', + 'headRefName': head_ref, + 'baseRefName': base_ref, + 'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)], + 'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)], + 'milestone': nested(data, 'milestone', 'title') or '', 'createdAt': data.get('created_at', ''), 'updatedAt': data.get('updated_at', ''), 'url': data.get('html_url', ''), @@ -102,11 +215,7 @@ normalized = { json.dump(normalized, sys.stdout, indent=2) ") - if [[ -n "$OUTPUT_FILE" ]]; then - echo "$METADATA" > "$OUTPUT_FILE" - else - echo "$METADATA" - fi + write_metadata "$METADATA" else echo "Error: Unknown platform" >&2 exit 1 diff --git a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh new file mode 100755 index 0000000..e98b2e7 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Regression harness for Gitea PR metadata normalization. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}" +REPO_DIR="$WORK_DIR/repo" +FIXTURE_DIR="$WORK_DIR/fixtures" + +rm -rf "$WORK_DIR" +mkdir -p "$REPO_DIR" "$FIXTURE_DIR" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git + +cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON' +{ + "number": 1905, + "title": "Smoke gate fix", + "state": "open", + "user": {"login": "edith"}, + "head": {"ref": "edith/t_39ce717c-authentik-smoke-gate"}, + "base": {"ref": "main"}, + "labels": [{"name": "ci"}], + "assignees": [{"login": "edith"}], + "html_url": "https://git.uscllc.com/USC/uconnect/pulls/1905" +} +JSON + +cat > "$FIXTURE_DIR/gitea-fallback.json" <<'JSON' +{ + "number": 1908, + "title": "Fallback branch fields", + "state": "open", + "user": {"login": "edith"}, + "head_branch": "fix/fallback-head", + "base_branch": "main", + "html_url": "https://git.uscllc.com/USC/uconnect/pulls/1908" +} +JSON + +cat > "$FIXTURE_DIR/gitea-refs-pull-label.json" <<'JSON' +{ + "number": 1908, + "title": "Closed merged PR with synthetic pull ref", + "state": "closed", + "user": {"login": "edith"}, + "head": {"ref": "refs/pull/1908/head", "label": "fix/t_23fa9e1d-portal-health-backend"}, + "base": {"ref": "main"}, + "html_url": "https://git.uscllc.com/USC/uconnect/pulls/1908" +} +JSON + +cat > "$FIXTURE_DIR/gitea-error.json" <<'JSON' +{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"} +JSON + +run_case() { + local fixture="$1" expected_number="$2" expected_head="$3" + local output + output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number") + PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY' +import json +import os +import sys + +data = json.loads(os.environ["PR_METADATA_OUTPUT"]) +expected_number = int(sys.argv[1]) +expected_head = sys.argv[2] +assert data["number"] == expected_number, data +assert data["baseRefName"] == "main", data +assert data["headRefName"] == expected_head, data +PY +} + +run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate +run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head +run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend + +if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then + echo "Expected API error fixture to fail" >&2 + exit 1 +fi +grep -q "Gitea API error" "$WORK_DIR/error.log" + +echo "Gitea PR metadata regression harness passed"