diff --git a/.gitea/scripts/review-check.sh b/.gitea/scripts/review-check.sh index b946b1726..24a6e94ea 100755 --- a/.gitea/scripts/review-check.sh +++ b/.gitea/scripts/review-check.sh @@ -60,6 +60,7 @@ # Optional: # REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines # REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha +# DEFAULT_BRANCH=main — branch this gate protects; non-default-base PRs no-op set -euo pipefail @@ -91,7 +92,7 @@ API="https://${GITEA_HOST}/api/v1" # secret token value in the process table for any process to read via # /proc//cmdline or ps -ef). The curl config file is read by curl # itself and never appears in the argv of the curl subprocess. -CURL_AUTH_FILE=$(mktemp -p /tmp curl-auth.XXXXXX) +CURL_AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.XXXXXX") chmod 600 "$CURL_AUTH_FILE" printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE" @@ -124,13 +125,19 @@ if [ "$HTTP_CODE" != "200" ]; then fi PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON") PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON") +PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON") PR_STATE=$(jq -r '.state // ""' "$PR_JSON") -debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_state=${PR_STATE}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" +debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}" if [ "$PR_STATE" != "open" ]; then echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)" exit 0 fi +if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then + echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable" + exit 0 +fi if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed" exit 1 diff --git a/.gitea/scripts/status-reaper.py b/.gitea/scripts/status-reaper.py index 9833e7b47..7047a7fc0 100644 --- a/.gitea/scripts/status-reaper.py +++ b/.gitea/scripts/status-reaper.py @@ -58,9 +58,10 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation: even if another tick happens before the runner finishes. What it does NOT do: - - Touch any context NOT ending in ` (push)`. The required-checks on - main (verified 2026-05-11) all have ` (pull_request)` suffixes; - they CANNOT be reached by this code path. + - Touch ` (pull_request)` contexts unless the exact same + workflow/job has a successful ` (push)` context on the same + default-branch SHA. That case is post-merge status pollution, not + an unproven PR gate. - Compensate `error`/`pending` states. Only `failure` — the only one Gitea emits for the hardcoded-suffix bug. - Write to non-default branches. WATCH_BRANCH is sourced from @@ -91,7 +92,9 @@ from __future__ import annotations import argparse import json import os +import socket import sys +import time import urllib.error import urllib.parse import urllib.request @@ -118,19 +121,28 @@ WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows") OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "") API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else "" +API_TIMEOUT_SEC = int(_env("STATUS_REAPER_API_TIMEOUT_SEC", default="30") or "30") +API_RETRIES = int(_env("STATUS_REAPER_API_RETRIES", default="3") or "3") +API_RETRY_SLEEP_SEC = float(_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2") # Compensating-status description prefix. Used as the marker so a human # auditing commit statuses can tell at a glance that the green was # synthetic, not a real CI pass. Kept stable; downstream tooling # (e.g. main-red-watchdog visual diff) MAY key on it. -COMPENSATION_DESCRIPTION = ( +PUSH_COMPENSATION_DESCRIPTION = ( "Compensated by status-reaper (workflow has no push: trigger; " "Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)" ) +PR_SHADOW_COMPENSATION_DESCRIPTION = ( + "Compensated by status-reaper (default-branch pull_request status " + "shadowed by successful push status on same SHA; see " + ".gitea/scripts/status-reaper.py)" +) # Context suffix the reaper acts on. Gitea hardcodes this for ALL # default-branch workflow runs. PUSH_SUFFIX = " (push)" +PULL_REQUEST_SUFFIX = " (pull_request)" def _require_runtime_env() -> None: @@ -182,13 +194,27 @@ def api( data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, method=method, data=data, headers=headers) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - raw = resp.read() - status = resp.status - except urllib.error.HTTPError as e: - raw = e.read() - status = e.code + attempts = max(API_RETRIES, 1) + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(req, timeout=API_TIMEOUT_SEC) as resp: + raw = resp.read() + status = resp.status + break + except urllib.error.HTTPError as e: + raw = e.read() + status = e.code + break + except (TimeoutError, socket.timeout, urllib.error.URLError, OSError) as e: + if attempt >= attempts: + raise ApiError( + f"{method} {path} failed after {attempts} attempts: {e}" + ) from e + print( + f"::warning::{method} {path} transient API error " + f"(attempt {attempt}/{attempts}): {e}; retrying" + ) + time.sleep(API_RETRY_SLEEP_SEC) if not (200 <= status < 300): snippet = raw[:500].decode("utf-8", errors="replace") if raw else "" @@ -357,24 +383,38 @@ def get_combined_status(sha: str) -> dict: # -------------------------------------------------------------------------- # Context parsing # -------------------------------------------------------------------------- -def parse_push_context(context: str) -> tuple[str, str] | None: - """Parse ` / (push)` into +def parse_suffixed_context(context: str, suffix: str) -> tuple[str, str] | None: + """Parse ` / ()` into (workflow_name, job_name). Returns None if the context doesn't match the shape (caller skips). - Strict: requires the trailing ` (push)` and at least one ` / ` + Strict: requires the trailing suffix and at least one ` / ` separator. Anything else is left alone. """ - if not context.endswith(PUSH_SUFFIX): + if not context.endswith(suffix): return None - head = context[: -len(PUSH_SUFFIX)] # strip " (push)" + head = context[: -len(suffix)] if " / " not in head: - # No workflow/job separator — not the bug shape we compensate. return None workflow_name, job_name = head.split(" / ", 1) return workflow_name, job_name +def parse_push_context(context: str) -> tuple[str, str] | None: + """Parse ` / (push)` into + (workflow_name, job_name).""" + return parse_suffixed_context(context, PUSH_SUFFIX) + + +def push_equivalent_context(context: str) -> str | None: + """Return the matching `(push)` context for a `(pull_request)` context.""" + parsed = parse_suffixed_context(context, PULL_REQUEST_SUFFIX) + if parsed is None: + return None + workflow_name, job_name = parsed + return f"{workflow_name} / {job_name}{PUSH_SUFFIX}" + + # -------------------------------------------------------------------------- # Compensating POST # -------------------------------------------------------------------------- @@ -383,6 +423,7 @@ def post_compensating_status( context: str, target_url: str | None, *, + description: str = PUSH_COMPENSATION_DESCRIPTION, dry_run: bool = False, ) -> None: """POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the @@ -394,7 +435,7 @@ def post_compensating_status( payload: dict[str, Any] = { "context": context, "state": "success", - "description": COMPENSATION_DESCRIPTION, + "description": description, } # Echo the original target_url when present so a human auditing # the (now-green) compensated status can still reach the run logs @@ -431,7 +472,8 @@ def reap( Returns counters for observability: {compensated, preserved_real_push, preserved_unknown, preserved_non_failure, preserved_non_push_suffix, - preserved_unparseable, + preserved_unparseable, compensated_pr_shadowed_by_push_success, + preserved_pr_without_push_success, compensated_contexts: [, ...]} `compensated_contexts` is rev2-added so `reap_branch` can build @@ -444,10 +486,17 @@ def reap( "preserved_non_failure": 0, "preserved_non_push_suffix": 0, "preserved_unparseable": 0, + "compensated_pr_shadowed_by_push_success": 0, + "preserved_pr_without_push_success": 0, "compensated_contexts": [], } statuses = combined.get("statuses") or [] + successful_contexts = { + (s.get("context") or "") + for s in statuses + if isinstance(s, dict) and (s.get("status") or s.get("state") or "") == "success" + } for s in statuses: if not isinstance(s, dict): continue @@ -471,9 +520,31 @@ def reap( counters["preserved_non_failure"] += 1 continue + # Default-branch `pull_request` contexts can be stale shadows of + # the exact same workflow/job already proven by the successful + # `push` context on the same SHA. Compensate only that narrow + # shape; a missing or failed push equivalent remains a real gate + # signal and is preserved. + push_equivalent = push_equivalent_context(context) + if push_equivalent is not None: + if push_equivalent in successful_contexts: + post_compensating_status( + sha, + context, + s.get("target_url"), + description=PR_SHADOW_COMPENSATION_DESCRIPTION, + dry_run=dry_run, + ) + counters["compensated"] += 1 + counters["compensated_pr_shadowed_by_push_success"] += 1 + counters["compensated_contexts"].append(context) + else: + counters["preserved_pr_without_push_success"] += 1 + continue + # Only `(push)`-suffix contexts hit the hardcoded-suffix bug. - # Branch-protection required checks (e.g. `Secret scan / Scan - # diff (pull_request)`) are NOT reachable from this path. + # Other failed contexts are preserved unless handled by the + # pull-request-shadow rule above. if not context.endswith(PUSH_SUFFIX): counters["preserved_non_push_suffix"] += 1 continue @@ -595,6 +666,8 @@ def reap_branch( "preserved_non_failure": 0, "preserved_non_push_suffix": 0, "preserved_unparseable": 0, + "compensated_pr_shadowed_by_push_success": 0, + "preserved_pr_without_push_success": 0, "compensated_per_sha": {}, } @@ -632,6 +705,8 @@ def reap_branch( "preserved_non_failure", "preserved_non_push_suffix", "preserved_unparseable", + "compensated_pr_shadowed_by_push_success", + "preserved_pr_without_push_success", ): aggregate[key] += per_sha[key] diff --git a/.gitea/scripts/tests/_review_check_fixture.py b/.gitea/scripts/tests/_review_check_fixture.py index e48a70c2f..51cc423f5 100644 --- a/.gitea/scripts/tests/_review_check_fixture.py +++ b/.gitea/scripts/tests/_review_check_fixture.py @@ -16,6 +16,7 @@ Scenarios: T7_team_member — team membership → 204 (member) → exit 0 T8_team_not_member — team membership → 404 (not a member) → exit 1 T9_team_403 — team membership → 403 (token not in team) → exit 1 + T14_non_default_base — open PR targeting staging → script exits 0 (no-op) Usage: FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080 @@ -82,12 +83,14 @@ class Handler(http.server.BaseHTTPRequestHandler): "number": int(pr_num), "state": "closed", "head": {"sha": "deadbeef0000111122223333444455556666"}, + "base": {"ref": "main"}, "user": {"login": "alice"}, }) return self._json(200, { "number": int(pr_num), "state": "open", "head": {"sha": "deadbeef0000111122223333444455556666"}, + "base": {"ref": "staging" if sc == "T14_non_default_base" else "main"}, "user": {"login": "alice"}, }) diff --git a/.gitea/scripts/tests/test_review_check.sh b/.gitea/scripts/tests/test_review_check.sh index 793089b5d..ed6169bfa 100755 --- a/.gitea/scripts/tests/test_review_check.sh +++ b/.gitea/scripts/tests/test_review_check.sh @@ -15,6 +15,7 @@ # T11 — bash syntax check (bash -n passes) # T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded # T13 — missing required env GITEA_TOKEN → exits 1 with error +# T14 — non-default-base PR exits 0 without requiring review # # Hostile-self-review (per feedback_assert_exact_not_substring): # this test MUST FAIL if the script is absent. Verified by running @@ -73,7 +74,7 @@ assert_file_mode() { return fi local got_mode - got_mode=$(stat -c '%a' "$path" 2>/dev/null || echo "000") + got_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null || echo "000") if [ "$expected_mode" = "$got_mode" ]; then echo " PASS $label (mode=$got_mode)" PASS=$((PASS + 1)) @@ -194,8 +195,9 @@ for a in "$@"; do done exec /usr/bin/curl "${new_args[@]}" CURL_SHIM -# Now substitute FIXPORT with the actual port number -sed -i "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl" +# Now substitute FIXPORT with the actual port number. Use perl rather than +# sed -i so the test runs on both GNU sed and BSD/macOS sed. +perl -0pi -e "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl" chmod +x "$FIXTURE_DIR/bin/curl" # Helper: run the script with fixture environment @@ -210,6 +212,7 @@ run_review_check() { GITEA_HOST="fixture.local" \ REPO="molecule-ai/molecule-core" \ PR_NUMBER="999" \ + DEFAULT_BRANCH="main" \ TEAM="qa" \ TEAM_ID="20" \ REVIEW_CHECK_DEBUG="0" \ @@ -253,6 +256,14 @@ T4_RC=$(cat "$FIX_STATE_DIR/last_rc") assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC" assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT" +# T14 — non-default-base PR should not make the default branch red. +echo +echo "== T14 non-default base PR ==" +T14_OUT=$(run_review_check "T14_non_default_base") +T14_RC=$(cat "$FIX_STATE_DIR/last_rc") +assert_eq "T14 exit code 0 (non-default base no-op)" "0" "$T14_RC" +assert_contains "T14 not applicable notice" "gate not applicable" "$T14_OUT" + # T5 — only author reviews → exit 1 echo echo "== T5 only author reviews ==" @@ -296,10 +307,10 @@ echo "== T10 CURL_AUTH_FILE ==" # Verify the token-file logic directly: create a temp file with the # same mktemp pattern, write the header with printf, chmod 600, then assert. T10_TOKEN="secret-test-token-abc123" -T10_AUTHFILE=$(mktemp -p /tmp curl-auth.test.XXXXXX) +T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX") chmod 600 "$T10_AUTHFILE" printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE" -assert_file_mode "T10a mktemp -p /tmp mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600" +assert_file_mode "T10a mktemp authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600" assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123" assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token ' rm -f "$T10_AUTHFILE" diff --git a/.gitea/scripts/tests/test_status_reaper_api.py b/.gitea/scripts/tests/test_status_reaper_api.py new file mode 100644 index 000000000..4296493d6 --- /dev/null +++ b/.gitea/scripts/tests/test_status_reaper_api.py @@ -0,0 +1,169 @@ +import importlib.util +import json +import pathlib +import urllib.error + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "status-reaper.py" + + +def load_reaper(): + spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + mod.API = "https://git.example.test/api/v1" + mod.GITEA_TOKEN = "test-token" + mod.API_TIMEOUT_SEC = 1 + mod.API_RETRIES = 3 + mod.API_RETRY_SLEEP_SEC = 0 + return mod + + +class FakeResponse: + status = 200 + + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def test_api_retries_transient_timeout(monkeypatch): + mod = load_reaper() + calls = {"n": 0} + + def fake_urlopen(req, timeout): + calls["n"] += 1 + if calls["n"] == 1: + raise TimeoutError("simulated slow Gitea API") + return FakeResponse({"ok": True}) + + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) + + status, body = mod.api("GET", "/repos/o/r/commits") + + assert status == 200 + assert body == {"ok": True} + assert calls["n"] == 2 + + +def test_api_raises_after_retry_budget(monkeypatch): + mod = load_reaper() + + def fake_urlopen(req, timeout): + raise urllib.error.URLError("connection reset") + + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) + + try: + mod.api("GET", "/repos/o/r/commits") + except mod.ApiError as exc: + assert "failed after 3 attempts" in str(exc) + else: + raise AssertionError("expected ApiError") + + +def test_reap_compensates_failed_pr_context_when_push_equivalent_passed(monkeypatch): + mod = load_reaper() + posted = [] + + def fake_post(sha, context, target_url, *, description="", dry_run=False): + posted.append((sha, context, target_url, description, dry_run)) + + monkeypatch.setattr(mod, "post_compensating_status", fake_post) + + counters = mod.reap( + {"CI": True, "Handlers Postgres Integration": True}, + { + "statuses": [ + { + "context": "CI / Platform (Go) (pull_request)", + "status": "failure", + "target_url": "https://git.example.test/ci-pr", + }, + { + "context": "CI / Platform (Go) (push)", + "status": "success", + }, + { + "context": ( + "Handlers Postgres Integration / " + "Handlers Postgres Integration (pull_request)" + ), + "status": "failure", + "target_url": "https://git.example.test/handlers-pr", + }, + { + "context": ( + "Handlers Postgres Integration / " + "Handlers Postgres Integration (push)" + ), + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_pr_shadowed_by_push_success"] == 2 + assert posted == [ + ( + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + "CI / Platform (Go) (pull_request)", + "https://git.example.test/ci-pr", + mod.PR_SHADOW_COMPENSATION_DESCRIPTION, + False, + ), + ( + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + "Handlers Postgres Integration / Handlers Postgres Integration (pull_request)", + "https://git.example.test/handlers-pr", + mod.PR_SHADOW_COMPENSATION_DESCRIPTION, + False, + ), + ] + + +def test_reap_preserves_failed_pr_context_without_push_success(monkeypatch): + mod = load_reaper() + posted = [] + monkeypatch.setattr( + mod, + "post_compensating_status", + lambda sha, context, target_url, *, description="", dry_run=False: posted.append( + context + ), + ) + + counters = mod.reap( + {"CI": True}, + { + "statuses": [ + { + "context": "CI / Platform (Go) (pull_request)", + "status": "failure", + }, + { + "context": "CI / Platform (Go) (push)", + "status": "failure", + }, + { + "context": "CI / Shellcheck (pull_request)", + "status": "failure", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["preserved_pr_without_push_success"] == 2 + assert posted == [] diff --git a/.gitea/workflows/cascade-list-drift-gate.yml b/.gitea/workflows/cascade-list-drift-gate.yml index e6f6ca463..a7230fa7b 100644 --- a/.gitea/workflows/cascade-list-drift-gate.yml +++ b/.gitea/workflows/cascade-list-drift-gate.yml @@ -43,6 +43,7 @@ permissions: contents: read jobs: + # bp-exempt: drift visibility gate; CI / all-required remains the required aggregate. check: runs-on: ubuntu-latest # Phase 3 (RFC #219 §1): surface broken workflows without blocking diff --git a/.gitea/workflows/gate-check-v3.yml b/.gitea/workflows/gate-check-v3.yml index ae615d366..b1175977e 100644 --- a/.gitea/workflows/gate-check-v3.yml +++ b/.gitea/workflows/gate-check-v3.yml @@ -44,6 +44,7 @@ env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: + # bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection. gate-check: runs-on: ubuntu-latest # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. @@ -63,6 +64,7 @@ jobs: if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != '' env: GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }} run: | @@ -77,6 +79,7 @@ jobs: if: github.event_name == 'schedule' env: GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} REPO: ${{ github.repository }} run: | set -euo pipefail diff --git a/.gitea/workflows/harness-replays.yml b/.gitea/workflows/harness-replays.yml index c570af887..e1c78f2f2 100644 --- a/.gitea/workflows/harness-replays.yml +++ b/.gitea/workflows/harness-replays.yml @@ -60,6 +60,7 @@ env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: + # bp-exempt: change detector only; downstream Harness Replays is the meaningful gate. detect-changes: runs-on: ubuntu-latest # Phase 3 (RFC #219 §1): surface broken workflows without blocking. @@ -132,7 +133,14 @@ jobs: RESP=$(curl -sS --fail --max-time 30 \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Accept: application/json" \ - "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD") + "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD") || { + # If Gitea's Compare API is slow/unavailable, choose the conservative + # behavior: run the harness instead of failing the detector and polluting + # main with a red non-gate context. + echo "run=true" >> "$GITHUB_OUTPUT" + echo "debug=compare-api-unavailable base=$BASE head=$HEAD" >> "$GITHUB_OUTPUT" + exit 0 + } DIFF_FILES=$(echo "$RESP" | bash .gitea/scripts/compare-api-diff-files.py 2>/dev/null || true) echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT" @@ -150,6 +158,7 @@ jobs: # matches e2e-api.yml — see that workflow's comment for why a # job-level `if: false` would block branch protection via the # SKIPPED-in-set bug. + # bp-exempt: path-filtered replay suite; CI / all-required is the branch-protection aggregate. harness-replays: needs: detect-changes name: Harness Replays diff --git a/.gitea/workflows/lint-continue-on-error-tracking.yml b/.gitea/workflows/lint-continue-on-error-tracking.yml index 4228466cb..cc06bca79 100644 --- a/.gitea/workflows/lint-continue-on-error-tracking.yml +++ b/.gitea/workflows/lint-continue-on-error-tracking.yml @@ -89,6 +89,7 @@ concurrency: cancel-in-progress: true jobs: + # bp-exempt: meta-lint for masked jobs; tracked separately until masks are burned down. lint: name: lint-continue-on-error-tracking runs-on: ubuntu-latest diff --git a/.gitea/workflows/lint-mask-pr-atomicity.yml b/.gitea/workflows/lint-mask-pr-atomicity.yml index a32cda5d2..758d62b58 100644 --- a/.gitea/workflows/lint-mask-pr-atomicity.yml +++ b/.gitea/workflows/lint-mask-pr-atomicity.yml @@ -84,6 +84,7 @@ concurrency: cancel-in-progress: true jobs: + # bp-exempt: meta-lint advisory during mask burn-down; CI / all-required gates merges. scan: name: lint-mask-pr-atomicity runs-on: ubuntu-latest diff --git a/.gitea/workflows/lint-required-no-paths.yml b/.gitea/workflows/lint-required-no-paths.yml index b994c7eff..08f045a84 100644 --- a/.gitea/workflows/lint-required-no-paths.yml +++ b/.gitea/workflows/lint-required-no-paths.yml @@ -69,6 +69,7 @@ concurrency: cancel-in-progress: true jobs: + # bp-exempt: meta-lint advisory; CI / all-required is the required aggregate. lint: name: lint-required-no-paths runs-on: ubuntu-latest diff --git a/.gitea/workflows/publish-canvas-image.yml b/.gitea/workflows/publish-canvas-image.yml index 62aac9cf5..9aedadd64 100644 --- a/.gitea/workflows/publish-canvas-image.yml +++ b/.gitea/workflows/publish-canvas-image.yml @@ -46,6 +46,7 @@ env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: + # bp-exempt: post-merge image publication side effect; CI / all-required gates source changes. build-and-push: name: Build & push canvas image # REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored. diff --git a/.gitea/workflows/publish-runtime-autobump.yml b/.gitea/workflows/publish-runtime-autobump.yml index ecdd9cade..5bd0814ad 100644 --- a/.gitea/workflows/publish-runtime-autobump.yml +++ b/.gitea/workflows/publish-runtime-autobump.yml @@ -53,6 +53,7 @@ jobs: # Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are # surfaced via continue-on-error: true rather than blocking the merge. # The actual bump work happens on the main/staging push after merge. + # bp-exempt: advisory validation for runtime publication; not a branch-protection gate. pr-validate: runs-on: ubuntu-latest # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. @@ -79,6 +80,7 @@ jobs: # Actual bump-and-tag: runs on main/staging pushes, posts real success/failure. # No continue-on-error — operational failures here trip the main-red # watchdog, which is the desired signal for infrastructure degradation. + # bp-exempt: post-merge tag publication side effect; CI / all-required gates source changes. bump-and-tag: runs-on: ubuntu-latest # Only fire on push events (main/staging after PR merge). Pull_request diff --git a/.gitea/workflows/qa-review.yml b/.gitea/workflows/qa-review.yml index 427fe03b2..c9360706e 100644 --- a/.gitea/workflows/qa-review.yml +++ b/.gitea/workflows/qa-review.yml @@ -93,6 +93,7 @@ permissions: pull-requests: read jobs: + # bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required. approved: # Gate the job: # - On pull_request_target events: always run. @@ -157,6 +158,7 @@ jobs: # pull_request_target → github.event.pull_request.number # issue_comment → github.event.issue.number PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} TEAM: qa TEAM_ID: '20' REVIEW_CHECK_DEBUG: '0' diff --git a/.gitea/workflows/redeploy-tenants-on-main.yml b/.gitea/workflows/redeploy-tenants-on-main.yml index 456c2542c..8568b2177 100644 --- a/.gitea/workflows/redeploy-tenants-on-main.yml +++ b/.gitea/workflows/redeploy-tenants-on-main.yml @@ -1,4 +1,4 @@ -name: manual-redeploy-tenants-on-main +name: redeploy-tenants-on-main # Ported from .github/workflows/redeploy-tenants-on-main.yml on 2026-05-11 per RFC # internal#219 §1 sweep. Differences from the GitHub version: @@ -9,21 +9,14 @@ name: manual-redeploy-tenants-on-main # - Workflow-level env.GITHUB_SERVER_URL pinned per # feedback_act_runner_github_server_url. # - `continue-on-error: true` on each job (RFC §1 contract). -# - Gitea 1.22.6 does not support workflow_run (task #81). This Gitea -# fallback is manual-only; automatic production deploy is attached to -# publish-workspace-server-image.yml after image push succeeds. +# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with +# push+paths filter per this PR. Gitea 1.22.6 does not support +# `workflow_run` (task #81). The push trigger fires on every +# commit to publish-workspace-server-image.yml which is the +# same signal (only successful runs commit to main). # -# Manual production tenant redeploy fallback. -# -# Primary automatic production deployment now lives in -# publish-workspace-server-image.yml: -# build images -> wait for `CI / all-required (push)` green on the same SHA -# -> call production redeploy-fleet. -# -# This workflow remains as an operator fallback. By default it reruns current -# main; set repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG to a known-good -# `staging-` tag for rollback. +# Auto-refresh prod tenant EC2s after every main merge. # # Why this workflow exists: publish-workspace-server-image builds and # pushes a new platform-tenant : to ECR on every merge to main, @@ -41,26 +34,60 @@ name: manual-redeploy-tenants-on-main # Gitea suspension migration. The staging-verify.yml promote step now # uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap). # -# Any failure aborts the rollout and leaves older tenants on the prior image. +# Runtime ordering: +# 1. publish-workspace-server-image completes → new :staging- in ECR. +# 2. This workflow fires via workflow_run, calls redeploy-fleet with +# target_tag=staging-. No CDN propagation wait needed — +# ECR image manifest is consistent immediately after push. +# 3. Calls redeploy-fleet with canary_slug (if set) and a soak +# period. Canary proves the image boots; batches follow. +# 4. Any failure aborts the rollout and leaves older tenants on the +# prior image — safer default than half-and-half state. +# +# Rollback path: re-run this workflow with a specific SHA pinned via +# the workflow_dispatch input. That calls redeploy-fleet with +# target_tag=, re-pulling the older image on every tenant. on: + push: + branches: [main] + paths: + - '.gitea/workflows/publish-workspace-server-image.yml' workflow_dispatch: permissions: contents: read # No write scopes needed — the workflow hits an external CP endpoint, # not the GitHub API. -# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite -# `cancel-in-progress: false`; operators should not dispatch overlapping manual -# production redeploys. +# Serialize redeploys so two rapid main pushes' redeploys don't overlap +# and cause confusing per-tenant SSM state. Without this, GitHub's +# implicit workflow_run queueing would *probably* serialize them, but +# the explicit block makes the invariant defensible. Mirrors the +# concurrency block on redeploy-tenants-on-staging.yml for shape parity. +# +# cancel-in-progress: false → aborting a half-rolled-out fleet would +# leave tenants stuck on whatever image they happened to be on when +# cancelled. Better to finish the in-flight rollout before starting +# the next one. +concurrency: + group: redeploy-tenants-on-main + cancel-in-progress: false env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: redeploy: + # Skip the auto-trigger if publish-workspace-server-image didn't + # actually succeed. workflow_run fires on any completion state; we + # don't want to redeploy against a half-built image. + # NOTE (Gitea port): workflow_dispatch trigger dropped; only the + # workflow_run path remains. + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest - continue-on-error: false + # Phase 3 (RFC #219 §1): surface broken workflows without blocking. + # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. + continue-on-error: true timeout-minutes: 25 steps: - name: Note on ECR propagation @@ -71,20 +98,30 @@ jobs: - name: Compute target tag id: tag - # Gitea 1.22.6 does not support workflow_dispatch inputs reliably. - # Use repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG for rollback. + # Resolution order: + # 1. Operator-supplied input (workflow_dispatch with explicit + # tag) → used verbatim. Lets ops pin `latest` for emergency + # rollback to last canary-verified digest, or pin a specific + # `staging-` to roll back to a known-good build. + # 2. Default → `staging-`. The just-published + # digest. Bypasses the `:latest` retag path that's currently + # dead (staging-verify soft-skips without canary fleet, so + # the only thing retagging `:latest` today is the manual + # promote-latest.yml — last run 2026-04-28). Auto-trigger + # from workflow_run uses workflow_run.head_sha; manual + # dispatch with no input falls through to github.sha. env: - HEAD_SHA: ${{ github.sha }} - MANUAL_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }} + INPUT_TAG: ${{ inputs.target_tag }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | set -euo pipefail - if [ -n "${MANUAL_TARGET_TAG:-}" ]; then - echo "target_tag=$MANUAL_TARGET_TAG" >> "$GITHUB_OUTPUT" - echo "Using operator-pinned manual target tag: $MANUAL_TARGET_TAG" + if [ -n "${INPUT_TAG:-}" ]; then + echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" + echo "Using operator-pinned tag: $INPUT_TAG" else SHORT="${HEAD_SHA:0:7}" echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT" - echo "Using manual fallback tag: staging-$SHORT (head_sha=$HEAD_SHA)" + echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)" fi - name: Call CP redeploy-fleet @@ -93,13 +130,13 @@ jobs: # CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this # repo's secrets for CI. env: - CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }} + CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }} CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }} - CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }} - SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }} - BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }} - DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || false }} + CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }} + SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }} + BATCH_SIZE: ${{ inputs.batch_size || '3' }} + DRY_RUN: ${{ inputs.dry_run || false }} run: | set -euo pipefail @@ -152,7 +189,7 @@ jobs: [ -z "$HTTP_CODE" ] && HTTP_CODE="000" echo "HTTP $HTTP_CODE" - jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true + cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE" # Pretty-print per-tenant results in the job summary so # ops can see which tenants were redeployed without drilling @@ -168,9 +205,9 @@ jobs: echo "" echo "### Per-tenant result" echo "" - echo '| Slug | Phase | SSM Status | Exit | Healthz | Error present |' - echo '|------|-------|------------|------|---------|---------------|' - jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true + echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |' + echo '|------|-------|------------|------|---------|-------|' + jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true } >> "$GITHUB_STEP_SUMMARY" if [ "$HTTP_CODE" != "200" ]; then @@ -209,10 +246,13 @@ jobs: # fail the workflow, which is what `ok=true` should have # guaranteed all along. # - # Manual Gitea fallback redeploys current main's staging- tag, so - # the expected SHA is github.sha. + # When the redeploy was triggered by workflow_dispatch with a + # specific tag (target_tag != "latest"), the expected SHA may + # not equal ${{ github.sha }} — in that case we resolve via + # GHCR's manifest. For workflow_run (default :latest) the + # workflow_run.head_sha is the SHA that just published. env: - EXPECTED_SHA: ${{ github.sha }} + EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }} # Tenant subdomain template — slugs from the response are # appended. Production CP issues `.moleculesai.app`; diff --git a/.gitea/workflows/redeploy-tenants-on-staging.yml b/.gitea/workflows/redeploy-tenants-on-staging.yml index 534d6ba8a..98f6b2276 100644 --- a/.gitea/workflows/redeploy-tenants-on-staging.yml +++ b/.gitea/workflows/redeploy-tenants-on-staging.yml @@ -73,6 +73,7 @@ env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: + # bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes. redeploy: runs-on: ubuntu-latest # Phase 3 (RFC #219 §1): surface broken workflows without blocking. diff --git a/.gitea/workflows/review-check-tests.yml b/.gitea/workflows/review-check-tests.yml index 623690140..b60515ed5 100644 --- a/.gitea/workflows/review-check-tests.yml +++ b/.gitea/workflows/review-check-tests.yml @@ -41,6 +41,7 @@ concurrency: cancel-in-progress: true jobs: + # bp-exempt: review tooling regression suite; CI / all-required is the required aggregate. test: name: review-check.sh regression tests runs-on: ubuntu-latest diff --git a/.gitea/workflows/security-review.yml b/.gitea/workflows/security-review.yml index 0c4c87c88..6e5a18445 100644 --- a/.gitea/workflows/security-review.yml +++ b/.gitea/workflows/security-review.yml @@ -20,6 +20,7 @@ permissions: pull-requests: read jobs: + # bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required. approved: # See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational # log only, NOT a gate) / A4 / A5 design rationale. @@ -65,6 +66,7 @@ jobs: GITEA_HOST: git.moleculesai.app REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} TEAM: security TEAM_ID: '21' REVIEW_CHECK_DEBUG: '0' diff --git a/.gitea/workflows/staging-verify.yml b/.gitea/workflows/staging-verify.yml index a02f5f799..752d30de9 100644 --- a/.gitea/workflows/staging-verify.yml +++ b/.gitea/workflows/staging-verify.yml @@ -82,6 +82,7 @@ env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: + # bp-exempt: post-merge staging verification side effect; CI / all-required gates merges. staging-smoke: runs-on: ubuntu-latest # Phase 3 (RFC #219 §1): surface broken workflows without blocking. @@ -190,6 +191,7 @@ jobs: echo "assertions in the staging-smoke step log above." } >> "$GITHUB_STEP_SUMMARY" + # bp-exempt: post-merge image promotion side effect; staging-smoke controls promotion. promote-to-latest: # On green, calls the CP redeploy-fleet endpoint with target_tag= # staging- to promote the verified ECR image. This is the same diff --git a/.gitea/workflows/status-reaper.yml b/.gitea/workflows/status-reaper.yml index c904ce5ce..9ddd63d59 100644 --- a/.gitea/workflows/status-reaper.yml +++ b/.gitea/workflows/status-reaper.yml @@ -84,7 +84,7 @@ permissions: jobs: reap: runs-on: ubuntu-latest - timeout-minutes: 3 + timeout-minutes: 8 steps: - name: Check out repo at default-branch HEAD # BASE checkout per `feedback_pull_request_target_workflow_from_base`. @@ -118,4 +118,7 @@ jobs: REPO: ${{ github.repository }} WATCH_BRANCH: ${{ github.event.repository.default_branch }} WORKFLOWS_DIR: .gitea/workflows + STATUS_REAPER_API_RETRIES: "4" + STATUS_REAPER_API_TIMEOUT_SEC: "20" + STATUS_REAPER_API_RETRY_SLEEP_SEC: "2" run: python3 .gitea/scripts/status-reaper.py diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 4bf3a9d43..deaf575c1 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -31,17 +31,25 @@ export function extractMessageText(body: Record | null): string if (text) return text; // Response: result.parts[].text or result.parts[].root.text + // Use the first part that has a direct text field; within that part, + // prefer direct text over root.text. Subsequent parts' root.text fields + // are ignored when a direct text exists in an earlier part. const result = body.result as Record | undefined; const rParts = (result?.parts || []) as Array>; - const rText = rParts - .map((p) => { - if (p.text) return p.text as string; - const root = p.root as Record | undefined; - return (root?.text as string) || ""; - }) - .filter(Boolean) - .join("\n"); - if (rText) return rText; + const firstPartWithText = rParts.find( + (p) => typeof p.text === "string" && (p.text as string) !== "" + ); + if (firstPartWithText) { + return firstPartWithText.text as string; + } + // No direct text found; use root.text from the first part (if present). + const firstPart = rParts[0]; + if (firstPart) { + const root = firstPart.root as Record | undefined; + if (typeof root?.text === "string" && root.text !== "") { + return root.text as string; + } + } if (typeof body.result === "string") return body.result; } catch { /* ignore */ } diff --git a/canvas/src/components/SearchDialog.tsx b/canvas/src/components/SearchDialog.tsx index 9f2a2e1fe..ac6a54eb9 100644 --- a/canvas/src/components/SearchDialog.tsx +++ b/canvas/src/components/SearchDialog.tsx @@ -91,19 +91,16 @@ export function SearchDialog() { if (!open) return null; return ( -
- {/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */} -
setOpen(false)} - aria-hidden="true" - /> - {/* Dialog */} +
setOpen(false)} + >
e.stopPropagation()} > {/* Search input */}
diff --git a/canvas/src/components/__tests__/ConversationTraceModal.test.tsx b/canvas/src/components/__tests__/ConversationTraceModal.test.tsx index 247e7b037..5065de293 100644 --- a/canvas/src/components/__tests__/ConversationTraceModal.test.tsx +++ b/canvas/src/components/__tests__/ConversationTraceModal.test.tsx @@ -87,11 +87,10 @@ describe("extractMessageText — response result format", () => { expect(extractMessageText(body)).toBe("Root response text"); }); - it("prefers parts[].text over parts[].root.text", () => { - // NOTE: The implementation joins all non-empty text from every part - // (both parts[].text and parts[].root.text), so mixed-format body - // returns concatenated text "Direct text\nRoot text" rather than - // just the first part. Update this test to reflect actual behavior. + it("prefers parts[].text over parts[].root.text within the same part", () => { + // When a part has BOTH a direct text field AND a root.text field, + // direct text wins. Subsequent parts' root.text fields are ignored + // when a direct text was found in an earlier part. const body = { result: { parts: [ @@ -100,8 +99,28 @@ describe("extractMessageText — response result format", () => { ], }, }; - // Implementation joins all parts with newlines: "Direct text\nRoot text" - expect(extractMessageText(body)).toBe("Direct text\nRoot text"); + expect(extractMessageText(body)).toBe("Direct text"); + }); + + it("falls back to root.text when no direct text exists", () => { + const body = { + result: { + parts: [{ root: { text: "Root only" } }], + }, + }; + expect(extractMessageText(body)).toBe("Root only"); + }); + + it("ignores subsequent parts root.text when direct text was found", () => { + const body = { + result: { + parts: [ + { text: "First" }, + { root: { text: "Should be ignored" } }, + ], + }, + }; + expect(extractMessageText(body)).toBe("First"); }); }); diff --git a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx index 59bdda128..f464036ae 100644 --- a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx +++ b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx @@ -1,102 +1,237 @@ // @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react"; -// Tests for the default-collapsed + expand-on-click behavior of the -// org templates drawer. Before this change the section rendered all -// org cards inline, which pushed the individual workspace templates -// off-screen when there were ≥3 orgs on disk. Collapsed-by-default -// keeps the scroll focused on the primary deploy path. - -vi.mock("@/lib/api", () => ({ - api: { - get: vi.fn().mockResolvedValue([ - { dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 }, - { dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 }, - ]), - post: vi.fn().mockResolvedValue({}), - }, +/** + * Tests for OrgTemplatesSection — collapsible org template import list. + * + * Covers: + * - Header with count badge (visible only when expanded) + * - Collapsed by default, aria-expanded toggles on click + * - aria-controls targets org-templates-body div + * - Empty state when no org templates + * - Loading spinner + * - Org template cards: name, description, workspace count + * - Import button per card + * - Preflight modal opens when org has required_env + * - Preflight onProceed fires import + * - Preflight onCancel closes modal + * - Direct import (no modal) when org has no env requirements + * - Import button disabled while that org is importing + */ +// ── ALL mocks MUST be before imports (vi.mock is hoisted to top of file) ─────── +const { mockGet, mockPost, mockListSecrets } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), + mockListSecrets: vi.fn(), })); -vi.mock("../Spinner", () => ({ Spinner: () => null })); -vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null })); -vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null })); -vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() })); +vi.mock("@/lib/api", () => ({ + api: { get: mockGet, post: mockPost }, +})); +vi.mock("@/lib/api/secrets", () => ({ + listSecrets: mockListSecrets, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn(), + { getState: () => ({ nodes: [], hydrate: vi.fn() }) }, + ), +})); + +vi.mock("../Spinner", () => ({ + Spinner: () =>