diff --git a/docs/PRD.md b/docs/PRD.md index 6f204bb..08b7907 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,8 +62,9 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and 19. `@mosaicstack/prdy` — PRD wizard 20. `@mosaicstack/quality-rails` — code quality scaffolder 21. `@mosaicstack/cli` — unified `mosaic` CLI -22. Docker Compose deployment + bare-metal capability -23. Agent log service — ingest, parse, tier, summarize agent interaction logs +22. Mosaic framework git wrappers — provider-aware issue/PR/CI shell wrappers for GitHub and self-hosted Gitea hosts used by Mosaic/USC repositories +23. Docker Compose deployment + bare-metal capability +24. Agent log service — ingest, parse, tier, summarize agent interaction logs ### Out of Scope (v0.1.0) diff --git a/docs/TASKS.md b/docs/TASKS.md index 49d9d03..b660041 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 | +| MVP-T07 | in-progress | Harden Mosaic framework Gitea PR metadata and merge preflight wrappers | Internal ref `t_a292e96f`; source branch `fix/gitea-pr-metadata-login-t-a292e96f` | ## Pointer to Active Workstream diff --git a/docs/scratchpads/t_a292e96f-gitea-pr-wrapper.md b/docs/scratchpads/t_a292e96f-gitea-pr-wrapper.md new file mode 100644 index 0000000..67e35eb --- /dev/null +++ b/docs/scratchpads/t_a292e96f-gitea-pr-wrapper.md @@ -0,0 +1,48 @@ +# t_a292e96f — Gitea PR metadata and merge wrapper fix + +## Objective + +Fix Mosaic git wrappers so Gitea repositories on `git.uscllc.com` resolve PR metadata and merge preflight through the correct host credentials, without selecting the stale `mosaicstack` Tea login. + +## Acceptance criteria + +- `pr-metadata.sh` returns `baseRefName=main` for U-Connect PR #1905 and PR #1908. +- `pr-metadata.sh` returns source-branch-style `headRefName`; for Gitea `refs/pull//head` responses, normalize to `head.label`. +- `pr-merge.sh` preserves Mosaic squash-only and base-branch policy, then uses host-matched Gitea API credentials for Gitea merges instead of a hard-coded Tea login. +- Add regression coverage/harness for Gitea metadata normalization and merge preflight. +- Do not print, log, or commit tokens. + +## Plan + +1. Reproduce current live metadata/login context with sanitized output. +2. Patch repo-source shell wrappers under `packages/mosaic/framework/tools/git/`. +3. Add a hermetic shell regression harness with fake `git`, `curl`, and `tea`. +4. Validate with `bash -n`, shellcheck if available, regression harness, and live sanitized U-Connect wrapper calls. +5. Apply the same script changes to the installed Mosaic wrapper location only after source changes validate, so active U-Connect merge wrappers are unblocked while the PR is reviewed. +6. Commit, push through queue guard, open PR, and hand off to Ultron review task `t_848435ab`; do not merge. + +## Progress + +- Live sanitized metadata check before source patch: + - PR #1905: `baseRefName=main`, `headRefName=edith/t_39ce717c-authentik-smoke-gate`. + - PR #1908: `baseRefName=main`, `headRefName=refs/pull/1908/head`; raw Gitea `head.label` is `fix/t_23fa9e1d-portal-health-backend`. + - `tea login list` contains only `git.mosaicstack.dev`, so the prior `--login mosaicstack` default cannot work for `git.uscllc.com`. + +## Verification log + +- `bash -n packages/mosaic/framework/tools/git/detect-platform.sh packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh` — pass. +- `shellcheck packages/mosaic/framework/tools/git/detect-platform.sh packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh` — pass when available in the Kanban runtime. +- `TMPDIR="$PWD/.agent-tmp" bash packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh` — pass; proves host-matched Gitea credential selection, metadata normalization, and merge dry-run preflight without invoking `tea`. +- Live sanitized U-Connect metadata using the patched wrapper from `/src/uconnect`: + - PR #1905: `number=1905`, `baseRefName=main`, `headRefName=edith/t_39ce717c-authentik-smoke-gate`, `state=open`. + - PR #1908: `number=1908`, `baseRefName=main`, `headRefName=fix/t_23fa9e1d-portal-health-backend`, `state=closed`. +- Live sanitized U-Connect merge preflight using `pr-merge.sh --skip-queue-guard --dry-run`: + - PR #1905: `Dry run: Gitea merge preflight OK for USC/uconnect#1905 targeting main via git.uscllc.com API`. + - PR #1908: `Dry run: Gitea merge preflight OK for USC/uconnect#1908 targeting main via git.uscllc.com API`. +- 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. + +## Risks / notes + +- `--dry-run` was added to `pr-merge.sh` to validate metadata/auth/preflight without merging a live PR. +- Gitea branch deletion after merge remains a documented warning, matching prior behavior, and is not expanded in this fix. +- Duplicate recovery PR #517 was closed after wrapper-first `pr-close.sh -n 517` failed headlessly with `/dev/tty`; PR #518 is the review target. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index c53e0af..dd796be 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -92,7 +92,7 @@ get_remote_host() { } # Resolve a Gitea API token for the given host. -# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials +# Priority: Mosaic credential loader → host-matched GITEA_TOKEN env → ~/.git-credentials get_gitea_token() { local host="$1" local script_dir @@ -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 a caller/global GITEA_TOKEN. + # load_credentials intentionally preserves existing env vars for interactive use, + # but merge/metadata wrappers need the token 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 diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index ad8c318..f5eb574 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -1,10 +1,11 @@ #!/bin/bash # pr-merge.sh - Merge pull requests on Gitea or GitHub -# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] +# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] [--dry-run] set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 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 @@ -94,19 +102,55 @@ REPO=$(get_repo_name) case "$PLATFORM" in github) - CMD="gh pr merge $PR_NUMBER --squash" - [[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch" - eval "$CMD" + if [[ "$DRY_RUN" == true ]]; then + echo "Dry run: GitHub merge preflight OK for ${OWNER}/${REPO}#${PR_NUMBER} targeting ${BASE_BRANCH}" + exit 0 + fi + CMD=(gh pr merge "$PR_NUMBER" --squash) + [[ "$DELETE_BRANCH" == true ]] && CMD+=(--delete-branch) + "${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 remote URL" >&2 + exit 1 + } + TOKEN=$(get_gitea_token "$HOST") || { + echo "Error: Could not resolve Gitea API token for ${HOST}" >&2 + exit 1 + } + + if [[ "$DRY_RUN" == true ]]; then + echo "Dry run: Gitea merge preflight OK for ${OWNER}/${REPO}#${PR_NUMBER} targeting ${BASE_BRANCH} via ${HOST} API" + exit 0 + fi + + RESPONSE_FILE=$(mktemp) + trap 'rm -f "$RESPONSE_FILE"' EXIT + HTTP_CODE=$(curl -sS \ + -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"Do":"squash"}' \ + -o "$RESPONSE_FILE" \ + -w '%{http_code}' \ + "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge") + RESPONSE_BODY=$(cat "$RESPONSE_FILE") + rm -f "$RESPONSE_FILE" + trap - EXIT + + if [[ ! "$HTTP_CODE" =~ ^2 ]]; then + echo "Error: Gitea PR merge failed for ${OWNER}/${REPO}#${PR_NUMBER} (HTTP ${HTTP_CODE})" >&2 + if [[ -n "$RESPONSE_BODY" ]]; then + printf '%s\n' "$RESPONSE_BODY" >&2 + fi + exit 1 + 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 + echo "Note: Branch deletion after merge may need to be done separately with the Gitea API" >&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..60d8463 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -5,6 +5,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 source "$SCRIPT_DIR/detect-platform.sh" # Parse arguments @@ -55,39 +56,51 @@ if [[ "$PLATFORM" == "github" ]]; then 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 + HOST=$(get_remote_host) || { echo "Error: Cannot determine host from 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) + RESPONSE_FILE=$(mktemp) + trap 'rm -f "$RESPONSE_FILE"' EXIT if [[ -n "$GITEA_API_TOKEN" ]]; then - RAW=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$API_URL") + HTTP_CODE=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" -o "$RESPONSE_FILE" -w '%{http_code}' "$API_URL") else - RAW=$(curl -sS "$API_URL") + HTTP_CODE=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' "$API_URL") + fi + RAW=$(cat "$RESPONSE_FILE") + rm -f "$RESPONSE_FILE" + trap - EXIT + + if [[ ! "$HTTP_CODE" =~ ^2 ]]; then + echo "Error: Gitea PR metadata request failed for ${OWNER}/${REPO}#${PR_NUMBER} (HTTP ${HTTP_CODE})" >&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) +if 'message' in data and not data.get('number'): + raise SystemExit('Error: Gitea PR metadata response did not contain PR data') +head = data.get('head') or {} +head_ref = head.get('ref') or '' +head_label = head.get('label') or '' +# Gitea can report closed/merged PR heads as refs/pull//head; callers need +# the source branch name equivalent to GitHub headRefName, so prefer label then. +if head_ref.startswith('refs/pull/') and head_label: + head_ref = head_label 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', ''), + 'headRefName': head_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', [])], diff --git a/packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh b/packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh new file mode 100755 index 0000000..f433c9e --- /dev/null +++ b/packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Regression harness for Gitea PR metadata normalization and merge preflight. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GIT_TOOLS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TEST_ROOT="${TEST_ROOT:-$(pwd)/.test-output/pr-gitea-wrapper-regression}" +FAKE_BIN="$TEST_ROOT/bin" +FAKE_REPO="$TEST_ROOT/repo" + +rm -rf "$TEST_ROOT" +mkdir -p "$FAKE_BIN" "$FAKE_REPO" "$TEST_ROOT/state" + +cat > "$FAKE_BIN/git" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "remote get-url origin" ]]; then + echo "https://git.uscllc.com/usc/uconnect.git" + exit 0 +fi +echo "unexpected git invocation: $*" >&2 +exit 2 +SH +chmod +x "$FAKE_BIN/git" + +cat > "$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +method="GET" +out_file="" +write_format="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -X) + method="$2"; shift 2 ;; + -o) + out_file="$2"; shift 2 ;; + -w) + write_format="$2"; shift 2 ;; + -H|-d) + shift 2 ;; + -s|-S|-f|-k|-sS|-fsS) + shift ;; + http*) + url="$1"; shift ;; + *) + shift ;; + esac +done + +body='{}' +code="200" +if [[ "$method" == "GET" && "$url" == *"/api/v1/repos/usc/uconnect/pulls/1908" ]]; then + body='{"number":1908,"title":"Test PR","body":"","state":"open","user":{"login":"edith"},"head":{"label":"fix/t_23fa9e1d-portal-health-backend","ref":"refs/pull/1908/head","sha":"abc123"},"base":{"label":"main","ref":"main","sha":"def456"},"labels":[],"assignees":[],"created_at":"2026-05-22T00:00:00Z","updated_at":"2026-05-22T00:00:00Z","html_url":"https://git.uscllc.com/usc/uconnect/pulls/1908","draft":false,"mergeable":true,"diff_url":"https://git.uscllc.com/usc/uconnect/pulls/1908.diff"}' +elif [[ "$method" == "POST" && "$url" == *"/api/v1/repos/usc/uconnect/pulls/1908/merge" ]]; then + echo "$url" > "${TEST_ROOT:?}/state/merge-url" + body='{"merged":true}' +else + code="404" + body='{"message":"not found"}' +fi + +if [[ -n "$out_file" ]]; then + printf '%s' "$body" > "$out_file" +else + printf '%s' "$body" +fi +if [[ -n "$write_format" ]]; then + printf '%s' "$code" +fi +SH +chmod +x "$FAKE_BIN/curl" + +cat > "$FAKE_BIN/tea" <<'SH' +#!/usr/bin/env bash +echo "tea must not be invoked by Gitea merge preflight" >&2 +exit 99 +SH +chmod +x "$FAKE_BIN/tea" + +cat > "$TEST_ROOT/credentials.json" <<'JSON' +{ + "gitea": { + "usc": {"url": "https://git.uscllc.com", "token": "fake-token-usc"}, + "mosaicstack": {"url": "https://git.mosaicstack.dev", "token": "fake-token-mosaic"} + } +} +JSON + +export PATH="$FAKE_BIN:$PATH" +export TEST_ROOT +export MOSAIC_CREDENTIALS_FILE="$TEST_ROOT/credentials.json" +cd "$FAKE_REPO" + +metadata="$("$GIT_TOOLS_DIR/pr-metadata.sh" -n 1908)" +python3 - "$metadata" <<'PY' +import json +import sys +metadata = json.loads(sys.argv[1]) +assert metadata["baseRefName"] == "main", metadata +assert metadata["headRefName"] == "fix/t_23fa9e1d-portal-health-backend", metadata +PY + +merge_output="$("$GIT_TOOLS_DIR/pr-merge.sh" -n 1908 -m squash --skip-queue-guard --dry-run 2>&1)" +if grep -q "mosaicstack\|Login name\|tea must not" <<<"$merge_output"; then + echo "$merge_output" >&2 + exit 1 +fi +if ! grep -q "Dry run: Gitea merge preflight OK" <<<"$merge_output"; then + echo "$merge_output" >&2 + exit 1 +fi + +printf 'Gitea PR metadata and merge preflight regression passed\n'