diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index bf60863e..ea4524b7 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -19,6 +19,41 @@ ISSUE="" # get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh +gitea_default_branch() { + # Forge default branch for the current repo (T51-P2 WP5a / spec E4): the + # API fallback must not guess a base. Empty output or any lookup failure + # returns nonzero so the caller fails loud instead of mistargeting a PR. + local host repo token url body branch + host=$(get_remote_host) || return 1 + repo=$(get_repo_info) || return 1 + token=$(get_gitea_token "$host") || return 1 + url="https://${host}/api/v1/repos/${repo}" + # Fetch and parse as separate steps (T51P2WP5AR B2): a piped + # `curl | python` reports only python's status, so an HTTP failure that + # still emits parseable JSON would masquerade as success. curl's own + # exit status is authoritative here. + if ! body=$(curl -fsS \ + -H "User-Agent: curl/8" \ + -H "Authorization: token ${token}" \ + "$url" 2>/dev/null); then + return 1 + fi + # A valid base is a NONBLANK JSON STRING (T51P2WP5AR B3): null, numbers, + # and whitespace-only values are failed resolution, never a POSTed base. + branch=$(printf '%s' "$body" | python3 -c ' +import json, sys +try: + value = json.load(sys.stdin).get("default_branch") +except Exception: + sys.exit(1) +if not isinstance(value, str) or not value.strip(): + sys.exit(1) +print(value.strip()) +' 2>/dev/null) || return 1 + [[ -n "$branch" ]] || return 1 + printf '%s' "$branch" +} + gitea_pr_create_api() { local host repo token url payload host=$(get_remote_host) || { @@ -38,14 +73,28 @@ gitea_pr_create_api() { echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2 fi - payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY' + # Base resolution (spec E4): an explicit -B always wins; with none, the + # forge default branch is resolved from the provider API -- never the + # historical "main" literal, which mistargeted every fallback PR on + # repos whose trunk is not main (e.g. mosaicstack/stack -> next). + local api_base="" + if [[ -n "$BASE_BRANCH" ]]; then + api_base="$BASE_BRANCH" + else + api_base=$(gitea_default_branch) || { + echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B explicitly" >&2 + return 1 + } + fi + + payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" API_BASE="$api_base" python3 - <<'PY' import json import os payload = { "title": os.environ["TITLE"], "head": os.environ["HEAD_BRANCH"], - "base": os.environ["BASE_BRANCH"] or "main", + "base": os.environ["API_BASE"], } body = os.environ.get("BODY", "") if body: @@ -72,7 +121,7 @@ Create a pull request on the current repository (Gitea or GitHub). Options: -t, --title TITLE PR title (required, or use --issue) -b, --body BODY PR description/body - -B, --base BRANCH Base branch to merge into (default: main/master) + -B, --base BRANCH Base branch to merge into (default: the forge repository's default branch) -H, --head BRANCH Head branch with changes (default: current branch) -l, --labels LABELS Comma-separated labels -m, --milestone NAME Milestone name diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index 10595c7f..736182f3 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -100,6 +100,15 @@ case "$url" in */commits/*/status) printf '{"state":"success","statuses":[{"context":"ci/mock","status":"success"}]}' ;; + # Repo roots: the pr-create API fallback resolves its base from the forge + # default_branch (T51-P2 WP5a). Exact-suffix matches so the /pulls POST + # endpoint (no trailing path) still falls through to the catch-all. + */api/v1/repos/USC/uconnect) + printf '{"default_branch":"main"}' + ;; + */api/v1/repos/mosaicstack/stack) + printf '{"default_branch":"next"}' + ;; *) printf '{}' ;; diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh index ecec60cb..5f899288 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -67,7 +67,18 @@ cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash set -euo pipefail printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG" -printf '%s\n' '{"number":703}' +# Repo roots: the pr-create API fallback resolves its base from the forge +# default_branch (T51-P2 WP5a). Exact-suffix so every other endpoint keeps +# the historical answer below. +url="${*: -1}" +case "$url" in + */api/v1/repos/mosaicstack/stack) + printf '%s\n' '{"default_branch":"next"}' + ;; + *) + printf '%s\n' '{"number":703}' + ;; +esac SH chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" diff --git a/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh b/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh new file mode 100755 index 00000000..2e18b182 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# test-pr-create-fallback-default-base.sh — hermetic test for the API-fallback +# base resolution in pr-create.sh (T51-P2 WP5a / spec E4). +# +# The API-fallback payload historically hardcoded "base": "main", mistargeting +# every fallback PR on repos whose trunk is not main (e.g. mosaicstack/stack, +# default branch "next"). The fix: an explicit -B always wins; with none, the +# base is resolved from the provider API default_branch, and a failed +# resolution fails loud instead of guessing. +# +# Hermetic by construction: every curl invocation is a PATH-first stub; the +# fixture repo's remote is git.example.test (never dialed); HOME is a sandbox +# with no tea config (so the wrapper takes the API fallback path); GITEA_TOKEN +# comes from the environment. No real forge is contacted. + +# shellcheck disable=SC2317 +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-fallback-base}" + +PASS=0 FAIL=0 FAILED_CASES="" + +ok() { PASS=$((PASS + 1)); } +bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; } + +assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; } +assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; } +assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; } + +json_field() { # $1 payload file, $2 field + python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$1" "$2" +} + +rm -rf "$WORK_DIR" + +# ---- fixture ----------------------------------------------------------------- +ROOT="$WORK_DIR/fixture" +TOOLS="$ROOT/tools/git" +mkdir -p "$TOOLS" "$ROOT/repo" "$ROOT/home" "$ROOT/stub" +cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS/pr-create.sh" +cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS/detect-platform.sh" + +git -C "$ROOT/repo" init -q -b fix/e4 +git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base +git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git + +# curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls -> +# capture payload, answer with a minimal PR JSON. Every call is logged. +cat > "$ROOT/stub/curl" <> "$ROOT/curl-calls.log" +url="\${!#}" +if [[ "\$url" == */api/v1/repos/acme/widgets ]]; then + # repo GET (default-branch resolution). Modes cover the value shapes the + # resolver must accept or refuse (T51P2WP5AR B2/B3). + case "\$mode" in + ok) printf '%s\n' '{"id":1,"default_branch":"next","full_name":"acme/widgets"}' ;; + fail) echo "curl stub: simulated repo lookup failure" >&2; exit 1 ;; + fail-json) printf '%s\n' '{"default_branch":"next"}'; exit 22 ;; + null) printf '%s\n' '{"default_branch":null}' ;; + numeric) printf '%s\n' '{"default_branch":7}' ;; + blank) printf '%s\n' '{"default_branch":" "}' ;; + *) echo "curl stub: unknown GET mode \$mode" >&2; exit 1 ;; + esac + exit 0 +fi +if [[ "\$url" == */api/v1/repos/acme/widgets/pulls ]]; then + # PR POST: capture the payload, emit a PR-shaped answer + while [[ \$# -gt 0 ]]; do + case "\$1" in + -d) printf '%s' "\$2" > "$ROOT/payload.json"; shift 2 ;; + *) shift ;; + esac + done + printf '%s\n' '{"number":42,"html_url":"https://git.example.test/acme/widgets/pulls/42"}' + exit 0 +fi +echo "curl stub: unexpected URL \$url" >&2 +exit 1 +STUB +chmod +x "$ROOT/stub/curl" + +run_pr_create() { # args... -> sets RC/OUT/ERR + RC=0 + OUT=$(cd "$ROOT/repo" && env -i \ + PATH="$ROOT/stub:/usr/bin:/bin" \ + HOME="$ROOT/home" \ + GITEA_TOKEN=stub-token \ + bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt") + RC=$? + ERR="$(cat "$ROOT/err.txt")" +} + +calls_matching() { grep -c -- "$1" "$ROOT/curl-calls.log" 2>/dev/null || true; } + +echo "== (1) no -B: fallback base resolves to the forge default branch, not main ==" +: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json" +run_pr_create -t "fix thing" +assert_rc "rc" 0 "$RC" +assert_contains "API fallback path taken (tea login unresolvable in fixture)" "$ERR" "trying Gitea API fallback" +assert_eq "repo GET performed" 1 "$(calls_matching '/api/v1/repos/acme/widgets$')" +assert_eq "POST performed" 1 "$(calls_matching '/pulls$')" +assert_eq "payload base is forge default (next)" "next" "$(json_field "$ROOT/payload.json" base)" +assert_eq "payload head" "fix/e4" "$(json_field "$ROOT/payload.json" head)" +assert_eq "payload title" "fix thing" "$(json_field "$ROOT/payload.json" title)" + +echo "== (2) explicit -B wins; the default branch is not consulted ==" +: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json" +run_pr_create -t "fix thing" -B release/1.x +assert_rc "rc" 0 "$RC" +assert_eq "repo GET not consulted for explicit base" 0 "$(calls_matching '/api/v1/repos/acme/widgets$')" +assert_eq "payload base is the explicit -B" "release/1.x" "$(json_field "$ROOT/payload.json" base)" + +echo "== (3) default-branch lookup failure: loud refusal, no POST ==" +: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json" +RC=0 +OUT=$(cd "$ROOT/repo" && env -i \ + PATH="$ROOT/stub:/usr/bin:/bin" \ + HOME="$ROOT/home" \ + GITEA_TOKEN=stub-token \ + CURL_STUB_GET_MODE=fail \ + bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt") +RC=$? +ERR="$(cat "$ROOT/err.txt")" +assert_rc "nonzero rc on unresolvable base" 1 "$RC" +assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch" +assert_contains "error names the remedy" "$ERR" "pass -B explicitly" +assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')" + +echo "== (4) payload never contains the literal fallback main ==" +: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json" +run_pr_create -t "fix thing" +assert_rc "rc" 0 "$RC" +assert_eq "base field is next, never main" "next" "$(json_field "$ROOT/payload.json" base)" + +echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resolution ==" +: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json" +RC=0 +OUT=$(cd "$ROOT/repo" && env -i \ + PATH="$ROOT/stub:/usr/bin:/bin" \ + HOME="$ROOT/home" \ + GITEA_TOKEN=stub-token \ + CURL_STUB_GET_MODE=fail-json \ + bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt") +RC=$? +ERR="$(cat "$ROOT/err.txt")" +assert_rc "nonzero rc on HTTP failure despite valid JSON" 1 "$RC" +assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch" +assert_contains "error names the remedy" "$ERR" "pass -B explicitly" +assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')" + +echo "== (6) B3: null / numeric / blank default_branch are failed resolutions ==" +for bad in null numeric blank; do + : > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json" + RC=0 + OUT=$(cd "$ROOT/repo" && env -i \ + PATH="$ROOT/stub:/usr/bin:/bin" \ + HOME="$ROOT/home" \ + GITEA_TOKEN=stub-token \ + CURL_STUB_GET_MODE="$bad" \ + bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt") + RC=$? + ERR="$(cat "$ROOT/err.txt")" + assert_rc "B3 $bad: nonzero rc" 1 "$RC" + assert_contains "B3 $bad: loud error" "$ERR" "could not resolve the forge default branch" + assert_eq "B3 $bad: no POST issued" 0 "$(calls_matching '/pulls$')" +done + +echo +echo "pass=$PASS fail=$FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED CASES:$FAILED_CASES" + exit 1 +fi +echo "ALL GREEN" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 2c8e873d..cc2a48a6 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",