diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 4155c7346..69c64daf7 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -185,6 +185,7 @@ jobs: test -f "$trusted_strix_source/scripts/ci/strix_quick_gate.sh" test -f "$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" test -f "$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" + test -f "$trusted_strix_source/scripts/ci/strix_resolve_target_visibility.py" { echo "TRUSTED_STRIX_SOURCE=$trusted_strix_source" echo "TRUSTED_STRIX_GATE=$trusted_strix_source/scripts/ci/strix_quick_gate.sh" @@ -265,19 +266,7 @@ jobs: TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository }} run: | set -euo pipefail - if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]]; then - echo "::error::Strix target repository must belong to ContextualWisdomLab." - exit 1 - fi - is_private="$(gh api "repos/${TARGET_REPOSITORY}" --jq '.private')" - case "$is_private" in - true | false) ;; - *) - echo "::error::Target repository visibility did not resolve to true or false." - exit 1 - ;; - esac - echo "is_private=$is_private" >>"$GITHUB_OUTPUT" + python3 "$TRUSTED_STRIX_SOURCE/scripts/ci/strix_resolve_target_visibility.py" - name: Materialize target workspace if: github.event_name != 'repository_dispatch' diff --git a/CHANGELOG.md b/CHANGELOG.md index e42afe76a..bd5a49334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,15 +29,13 @@ Semantic Versioning where the repository publishes a release. - Run the bounded fast-mlsirm repair heartbeat at minute 49 of every hour with one-dispatch scope and a two-hour same-head floor, without weakening true-parameter recovery, CPU/GPU parity, skipped-test, or Rust-ownership gates. - Use NVIDIA NIM `mistralai/mistral-small-4-119b-2603` with explicit high reasoning for scheduled repair and `nvidia/nemotron-3-nano-30b-a3b` for bounded helper work instead of GitHub Models in the write-capable autofix worker. - Apply one NUL-delimited exact-path and complete pre/post-worktree verification contract to both ordinary review repair and merge-conflict repair rather than relying on a visible post-model diff for the ordinary path. - -### Changed - - Avoided the expensive R/testthat failure-summary regular expression on marker-absent bounded logs by checking the required terminal marker first, while preserving fail-closed handling for incomplete or malformed failure evidence. ### Fixed - Refused PR Review Merge Scheduler head mutations, `update-branch` and the last-push approval head restamp, whenever the resolved mutation credential is the workflow `GITHUB_TOKEN`. GitHub starts no workflow run for events created with that credential, so the moved head collected no current-head required checks and the PR stayed permanently `BLOCKED` with a `github-actions[bot]` merge commit that no later scheduler run could repair, because the branch was no longer behind. The scheduler now waits with `head_mutation_credential_upgrade` guidance naming `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, and the OpenCode app token exchange. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. +- Retried transient GitHub visibility lookups in the required Strix job so a timeout, 5xx, 429, empty/non-boolean response, or authenticated HTTP 403 rate-limit (installation budget / secondary rate limit) no longer aborts the scan before it starts. Rate-limit 403 uses a distinct 3-attempt budget and a 30–60s bounded wait (Retry-After / X-RateLimit-Reset when present, otherwise 30/60s). A real 401/403/404 on a missing or unauthorized repository stays fail-closed and is never treated as a source finding. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. diff --git a/organization_commercial_readiness_fixtures.py b/organization_commercial_readiness_fixtures.py index d86596196..4b7ac7452 100644 --- a/organization_commercial_readiness_fixtures.py +++ b/organization_commercial_readiness_fixtures.py @@ -90,6 +90,7 @@ def __init__( repositories: list[dict[str, Any]], snapshots: dict[str, list[RepositorySnapshot | Exception]], ) -> None: + """Initialize deterministic repository and snapshot responses.""" self.repositories = repositories self.snapshots = snapshots self.dispatched_repairs: list[tuple[str, str]] = [] diff --git a/scripts/ci/organization_commercial_readiness_loop.py b/scripts/ci/organization_commercial_readiness_loop.py index c00cfa1e0..84c4f326c 100644 --- a/scripts/ci/organization_commercial_readiness_loop.py +++ b/scripts/ci/organization_commercial_readiness_loop.py @@ -239,6 +239,7 @@ class GitHubClient: """Use the GitHub CLI as an authenticated, bounded REST transport.""" def __init__(self, token: str, *, timeout_seconds: int = 60) -> None: + """Initialize the authenticated client with a bounded request timeout.""" if not token: raise GitHubError("GH_TOKEN is required for organization coordination") self._token = token @@ -853,4 +854,4 @@ def main( if __name__ == "__main__": # pragma: no cover - exercised through main() - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/scripts/ci/strix_resolve_target_visibility.py b/scripts/ci/strix_resolve_target_visibility.py new file mode 100644 index 000000000..fe99ce6e7 --- /dev/null +++ b/scripts/ci/strix_resolve_target_visibility.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Resolve Strix target-repository visibility with fail-closed retries. + +The required Strix job used a single ``gh api`` call. A transient GitHub API +flake (timeout, 5xx, 429, empty/non-boolean body, or authenticated HTTP 403 +rate-limit) aborted the scan before it started. Generic flakes retry a few +times with short backoff. Authenticated HTTP 403 rate-limits use a shorter +attempt budget and a longer bounded wait, honoring Retry-After / +X-RateLimit-Reset from ``gh`` output when present and capping the sleep so +the job cannot stall. A real 401/403/404 on a missing or unauthorized +repository stays fail-closed and is never treated as success or as a source +finding. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import time +from collections.abc import Callable +from pathlib import Path + +TARGET_REPOSITORY_RE = re.compile(r"^ContextualWisdomLab/[A-Za-z0-9_.-]+$") +HTTP_STATUS_RE = re.compile(r"\bHTTP[ /](\d{3})\b", re.IGNORECASE) +TOKEN_RE = re.compile(r"(gh[pousr]_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+)") +RETRY_AFTER_RE = re.compile(r"(?i)\bretry-after\s*[:=]\s*(\d+)\b") +RATE_LIMIT_RESET_RE = re.compile(r"(?i)\bx-ratelimit-reset\s*[:=]\s*(\d+)\b") +PERMANENT_HTTP_STATUSES = frozenset({401, 403, 404}) +TRANSIENT_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504}) +RATE_LIMIT_MARKERS = ( + "api rate limit exceeded", + "secondary rate limit", +) +TRANSIENT_MARKERS = ( + "timeout", + "timed out", + "connection reset", + "connection refused", + "connection timed out", + "context deadline exceeded", + "gateway timeout", + "i/o timeout", + "unexpected end of json", + "unexpected eof", + "temporary failure", + "server error", + "service unavailable", +) +DEFAULT_MAX_ATTEMPTS = 4 +RATE_LIMIT_MAX_ATTEMPTS = 3 +DEFAULT_TIMEOUT_SECONDS = 20.0 +MAX_BACKOFF_SECONDS = 4.0 +RATE_LIMIT_BASE_BACKOFF_SECONDS = 30.0 +RATE_LIMIT_MIN_BACKOFF_SECONDS = 5.0 +RATE_LIMIT_MAX_BACKOFF_SECONDS = 60.0 + + +def split_gh_response(output: str) -> tuple[str, str]: + """Return HTTP headers and body from ``gh api --include`` output.""" + status = re.search(r"(?m)^HTTP/[^\r\n]*\r?\n", output or "") + if status is None: + return "", output or "" + separator = re.search(r"\r?\n\r?\n", output[status.end() :]) + if separator is None: + return output, "" + body_start = status.end() + separator.end() + return output[:body_start], output[body_start:] + + +class VisibilityResolutionError(RuntimeError): + """Fail-closed visibility lookup that must stop the Strix job.""" + + +class VisibilityCommandError(RuntimeError): + """A ``gh api`` invocation failed before a boolean visibility was parsed.""" + + +def scrub_sensitive_data(text: str) -> str: + """Redact GitHub token prefixes before they can reach job logs.""" + return TOKEN_RE.sub("", text) + + +def validate_target_repository(repository: str) -> str: + """Return a ContextualWisdomLab repository name or fail closed.""" + candidate = (repository or "").strip() + if not TARGET_REPOSITORY_RE.fullmatch(candidate): + raise VisibilityResolutionError( + "Strix target repository must belong to ContextualWisdomLab." + ) + return candidate + + +def parse_private_flag(raw: str | None) -> str | None: + """Return ``true`` or ``false`` when visibility is an exact boolean.""" + value = (raw or "").strip() + if value in {"true", "false"}: + return value + return None + + +def is_github_rate_limit_failure(message: str) -> bool: + """Return whether a GitHub error is authenticated quota exhaustion.""" + folded = (message or "").lower() + return any(marker in folded for marker in RATE_LIMIT_MARKERS) + + +def classify_gh_failure(message: str) -> str: + """Classify a ``gh api`` failure as permanent, transient, or unknown.""" + if is_github_rate_limit_failure(message): + return "transient" + status_match = HTTP_STATUS_RE.search(message or "") + if status_match is not None: + status = int(status_match.group(1)) + if status in PERMANENT_HTTP_STATUSES: + return "permanent" + if status in TRANSIENT_HTTP_STATUSES: + return "transient" + return "unknown" + folded = (message or "").lower() + if any(marker in folded for marker in TRANSIENT_MARKERS): + return "transient" + return "unknown" + + +def run_gh_visibility( + repository: str, timeout: float = DEFAULT_TIMEOUT_SECONDS +) -> str: + """Return only the boolean body from ``gh api`` response evidence.""" + argv = ["gh", "api", f"repos/{repository}", "--include", "--jq", ".private"] + try: + completed = subprocess.run( + argv, + capture_output=True, + text=True, + shell=False, + check=False, + timeout=timeout, + ) + except OSError as exc: + raise VisibilityCommandError( + scrub_sensitive_data(f"gh api could not start: {exc}") + ) from exc + if completed.returncode != 0: + detail = scrub_sensitive_data( + "\n".join(part for part in (completed.stdout or "", completed.stderr or "") if part).strip() + ) + raise VisibilityCommandError( + detail or f"gh api exited {completed.returncode}" + ) + _headers, body = split_gh_response(completed.stdout) + return body + + +def backoff_seconds(attempt: int) -> float: + """Return a short exponential backoff capped for queue health.""" + return min(float(2 ** (attempt - 1)), MAX_BACKOFF_SECONDS) + + +def parse_rate_limit_wait_seconds( + message: str, *, now: float | None = None +) -> float | None: + """Return a wait from Retry-After or X-RateLimit-Reset when present.""" + text = message or "" + retry_after = RETRY_AFTER_RE.search(text) + if retry_after is not None: + return float(retry_after.group(1)) + reset = RATE_LIMIT_RESET_RE.search(text) + if reset is None: + return None + current = time.time() if now is None else now + delay = float(reset.group(1)) - current + if delay <= 0: + return None + return delay + + +def rate_limit_backoff_seconds( + attempt: int, message: str = "", *, now: float | None = None +) -> float: + """Return a bounded rate-limit wait, honoring headers when present.""" + parsed = parse_rate_limit_wait_seconds(message, now=now) + if parsed is None: + parsed = min( + RATE_LIMIT_BASE_BACKOFF_SECONDS * float(2 ** (attempt - 1)), + RATE_LIMIT_MAX_BACKOFF_SECONDS, + ) + parsed = max(parsed, RATE_LIMIT_MIN_BACKOFF_SECONDS) + if parsed > RATE_LIMIT_MAX_BACKOFF_SECONDS: + print( + "GitHub visibility rate-limit retry sleep capped from " + f"{parsed:g} to {RATE_LIMIT_MAX_BACKOFF_SECONDS:g} seconds.", + file=sys.stderr, + ) + return RATE_LIMIT_MAX_BACKOFF_SECONDS + return parsed + + +def fetch_repository_visibility( + repository: str, + *, + run_gh: Callable[[str], str] | None = None, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.time, + max_attempts: int = DEFAULT_MAX_ATTEMPTS, +) -> str: + """Return exact ``true``/``false`` visibility after bounded retries.""" + target = validate_target_repository(repository) + if max_attempts < 1: + raise VisibilityResolutionError( + "Visibility lookup requires at least one attempt." + ) + runner = run_gh or run_gh_visibility + last_error = "Target repository visibility did not resolve to true or false." + transient_attempts = 0 + rate_limit_attempts = 0 + total_attempts = 0 + total_attempt_cap = max_attempts + RATE_LIMIT_MAX_ATTEMPTS + while total_attempts < total_attempt_cap: # pragma: no branch - terminal failure raises + total_attempts += 1 + kind = "transient" + rate_limited = False + try: + parsed = parse_private_flag(runner(target)) + except subprocess.TimeoutExpired as exc: + last_error = scrub_sensitive_data( + f"GitHub visibility lookup timed out: {exc}" + ) + except VisibilityCommandError as exc: + last_error = str(exc) + kind = classify_gh_failure(last_error) + rate_limited = is_github_rate_limit_failure(last_error) + if kind == "permanent": + raise VisibilityResolutionError( + "Target repository visibility lookup was denied or missing: " + f"{last_error}" + ) from exc + else: + if parsed is not None: + return parsed + last_error = ( + "Target repository visibility did not resolve to true or false." + ) + if rate_limited: + rate_limit_attempts += 1 + if rate_limit_attempts >= RATE_LIMIT_MAX_ATTEMPTS: + break + else: + transient_attempts += 1 + if kind != "transient": + break + if rate_limited: + delay = rate_limit_backoff_seconds(rate_limit_attempts, last_error, now=now()) + label = "rate-limit" + else: + if transient_attempts >= max_attempts: + break + delay = backoff_seconds(transient_attempts) + label = "transient" + print( + f"{label.capitalize()} GitHub visibility lookup failure on attempt " + f"{rate_limit_attempts if rate_limited else transient_attempts}/" + f"{RATE_LIMIT_MAX_ATTEMPTS if rate_limited else max_attempts} " + f"(total {total_attempts}/{total_attempt_cap}); " + f"retrying in {delay:g}s.", + file=sys.stderr, + ) + sleep(delay) + if is_github_rate_limit_failure(last_error): + raise VisibilityResolutionError( + "Target repository visibility lookup hit a GitHub API rate-limit; " + "this is infrastructure, not a source finding: " + f"{last_error}" + ) + raise VisibilityResolutionError(last_error) + + +def write_is_private_output(path: Path, is_private: str) -> None: + """Append the exact visibility boolean to ``GITHUB_OUTPUT``.""" + with path.open("a", encoding="utf-8") as handle: + handle.write(f"is_private={is_private}\n") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse repository and GitHub-output destinations.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repository", + default=os.environ.get("TARGET_REPOSITORY", ""), + help="ContextualWisdomLab owner/name target repository", + ) + parser.add_argument( + "--github-output", + default=os.environ.get("GITHUB_OUTPUT", ""), + help="Path to the GitHub Actions GITHUB_OUTPUT file", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Resolve visibility and write ``is_private`` or fail closed.""" + args = parse_args(argv) + try: + if not str(args.github_output or "").strip(): + raise VisibilityResolutionError("GITHUB_OUTPUT is unset.") + is_private = fetch_repository_visibility(str(args.repository)) + write_is_private_output(Path(args.github_output), is_private) + except VisibilityResolutionError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_strix_resolve_target_visibility.py b/tests/test_strix_resolve_target_visibility.py new file mode 100644 index 000000000..cc3660d32 --- /dev/null +++ b/tests/test_strix_resolve_target_visibility.py @@ -0,0 +1,692 @@ +"""Prove Strix visibility lookup retries flakes and stays fail-closed.""" + +from __future__ import annotations + +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.ci import strix_resolve_target_visibility as visibility + + +REPO_ROOT = Path(__file__).resolve().parents[1] +STRIX_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "strix.yml" +NOEMA_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "noema-review.yml" + + +def _workflow_step(workflow: str, name: str) -> str: + """Return one named GitHub Actions step from a workflow document.""" + marker = f" - name: {name}\n" + if marker not in workflow: + raise AssertionError(f"workflow step not found: {name}") + start = workflow.index(marker) + try: + end = workflow.index("\n - name:", start + len(marker)) + except ValueError: + end = len(workflow) + return workflow[start:end] + + +class _ScriptedGh: + """Return scripted ``gh api`` outcomes in call order.""" + + def __init__(self, outcomes: list[object]) -> None: + """Initialize outcomes and an observable repository-call log.""" + self.outcomes = list(outcomes) + self.calls: list[str] = [] + + def __call__(self, repository: str) -> str: + """Return the next scripted result or raise its scripted exception.""" + self.calls.append(repository) + outcome = self.outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return str(outcome) + + +def test_transient_visibility_api_failure_retries_then_succeeds() -> None: + """A 502 then a boolean visibility must retry and continue the scan.""" + runner = _ScriptedGh( + [ + visibility.VisibilityCommandError("gh: HTTP 502: Bad Gateway"), + "true\n", + ] + ) + sleeps: list[float] = [] + + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/aFIPC", + run_gh=runner, + sleep=sleeps.append, + ) + == "true" + ) + assert runner.calls == ["ContextualWisdomLab/aFIPC"] * 2 + assert sleeps == [1.0] + + +def test_empty_and_non_boolean_visibility_retries_then_succeeds() -> None: + """Empty or non-boolean bodies are transient and must not abort the scan.""" + runner = _ScriptedGh(["", "null\n", "false"]) + sleeps: list[float] = [] + + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/kaefa", + run_gh=runner, + sleep=sleeps.append, + ) + == "false" + ) + assert runner.calls == ["ContextualWisdomLab/kaefa"] * 3 + assert sleeps == [1.0, 2.0] + + +def test_timeout_visibility_lookup_retries_then_succeeds() -> None: + """A GitHub API timeout is retried instead of failing the job in 1s.""" + runner = _ScriptedGh( + [ + subprocess.TimeoutExpired(["gh", "api"], 20), + "true", + ] + ) + sleeps: list[float] = [] + + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/.github", + run_gh=runner, + sleep=sleeps.append, + ) + == "true" + ) + assert sleeps == [1.0] + + +def test_permanent_invalid_visibility_fails_closed() -> None: + """Exhausted empty/non-boolean responses stay fail-closed.""" + runner = _ScriptedGh(["", "True", "1", "maybe"]) + sleeps: list[float] = [] + + with pytest.raises( + visibility.VisibilityResolutionError, + match="did not resolve to true or false", + ): + visibility.fetch_repository_visibility( + "ContextualWisdomLab/aFIPC", + run_gh=runner, + sleep=sleeps.append, + ) + assert runner.calls == ["ContextualWisdomLab/aFIPC"] * 4 + assert sleeps == [1.0, 2.0, 4.0] + + +# Exact downstream wording from ContextualWisdomLab/inkspan#160 required +# Strix job 95492526891 (run 32064279893) at 2026-08-17 20:12:47 UTC. +INSTALLATION_RATE_LIMIT_403 = ( + "gh: HTTP 403: API rate limit exceeded for installation ID 141441800 " + "(https://api.github.com/repos/ContextualWisdomLab/inkspan)" +) +SECONDARY_RATE_LIMIT_403 = ( + "gh: HTTP 403: You have exceeded a secondary rate limit. " + "Please wait a few minutes before you try again." +) +AUTH_DENIED_403 = "gh: HTTP 403: Resource not accessible by integration" + + +def test_http_404_and_403_are_not_success_and_are_not_retried() -> None: + """A missing or unauthorized repository must fail closed immediately.""" + sleeps: list[float] = [] + missing = _ScriptedGh([visibility.VisibilityCommandError("gh: HTTP 404: Not Found")]) + denied = _ScriptedGh([visibility.VisibilityCommandError(AUTH_DENIED_403)]) + + with pytest.raises( + visibility.VisibilityResolutionError, + match="denied or missing: gh: HTTP 404", + ): + visibility.fetch_repository_visibility( + "ContextualWisdomLab/missing-repo", + run_gh=missing, + sleep=sleeps.append, + ) + with pytest.raises( + visibility.VisibilityResolutionError, + match="denied or missing: gh: HTTP 403: Resource not accessible", + ): + visibility.fetch_repository_visibility( + "ContextualWisdomLab/private-denied", + run_gh=denied, + sleep=sleeps.append, + ) + assert missing.calls == ["ContextualWisdomLab/missing-repo"] + assert denied.calls == ["ContextualWisdomLab/private-denied"] + assert sleeps == [] + + +def test_installation_rate_limit_403_retries_then_preserves_visibility() -> None: + """Inkspan installation-budget 403 is transient, not an auth/missing repo.""" + runner = _ScriptedGh( + [ + visibility.VisibilityCommandError(INSTALLATION_RATE_LIMIT_403), + "false\n", + ] + ) + sleeps: list[float] = [] + + assert visibility.classify_gh_failure(INSTALLATION_RATE_LIMIT_403) == "transient" + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/inkspan", + run_gh=runner, + sleep=sleeps.append, + ) + == "false" + ) + assert runner.calls == ["ContextualWisdomLab/inkspan"] * 2 + assert sleeps == [30.0] + + +def test_secondary_rate_limit_403_retries_then_preserves_visibility() -> None: + """Secondary-rate-limit 403 wording is the same transient family.""" + runner = _ScriptedGh( + [ + visibility.VisibilityCommandError(SECONDARY_RATE_LIMIT_403), + "true", + ] + ) + sleeps: list[float] = [] + + assert visibility.classify_gh_failure(SECONDARY_RATE_LIMIT_403) == "transient" + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/inkspan", + run_gh=runner, + sleep=sleeps.append, + ) + == "true" + ) + assert runner.calls == ["ContextualWisdomLab/inkspan"] * 2 + assert sleeps == [30.0] + + +def test_exhausted_rate_limit_403_stays_typed_infrastructure_failure() -> None: + """Quota exhaustion must stay non-passing and must not look like a finding.""" + runner = _ScriptedGh( + [visibility.VisibilityCommandError(INSTALLATION_RATE_LIMIT_403)] * 3 + ) + sleeps: list[float] = [] + + with pytest.raises( + visibility.VisibilityResolutionError, + match="GitHub API rate-limit; this is infrastructure, not a source finding", + ) as excinfo: + visibility.fetch_repository_visibility( + "ContextualWisdomLab/inkspan", + run_gh=runner, + sleep=sleeps.append, + ) + assert "installation ID 141441800" in str(excinfo.value) + assert "denied or missing" not in str(excinfo.value) + assert runner.calls == ["ContextualWisdomLab/inkspan"] * 3 + assert sleeps == [30.0, 60.0] + + +def test_rate_limit_403_is_not_confused_with_authorization_403() -> None: + """Only the authenticated quota family is retryable; other 403s stay closed.""" + assert visibility.classify_gh_failure(INSTALLATION_RATE_LIMIT_403) == "transient" + assert visibility.classify_gh_failure(SECONDARY_RATE_LIMIT_403) == "transient" + assert visibility.classify_gh_failure(AUTH_DENIED_403) == "permanent" + assert ( + visibility.classify_gh_failure("gh: HTTP 403: API rate limit exceeded") + == "transient" + ) + assert visibility.classify_gh_failure("gh: HTTP 403: Forbidden") == "permanent" + + +def test_rate_limit_retry_after_is_honored_and_capped() -> None: + """Honor Retry-After from gh output, but never sleep an unbounded reset.""" + assert visibility.parse_rate_limit_wait_seconds("Retry-After: 12") == 12.0 + assert ( + visibility.parse_rate_limit_wait_seconds( + "X-RateLimit-Reset: 1700000010", + now=1_700_000_000.0, + ) + == 10.0 + ) + assert ( + visibility.rate_limit_backoff_seconds( + 1, + "gh: HTTP 403: API rate limit exceeded\nRetry-After: 12", + ) + == 12.0 + ) + assert ( + visibility.rate_limit_backoff_seconds( + 1, + "gh: HTTP 403: API rate limit exceeded\nRetry-After: 90", + ) + == 60.0 + ) + assert ( + visibility.rate_limit_backoff_seconds( + 1, + "gh: HTTP 403: API rate limit exceeded\nRetry-After: 0", + ) + == 5.0 + ) + runner = _ScriptedGh( + [ + visibility.VisibilityCommandError( + INSTALLATION_RATE_LIMIT_403 + "\nRetry-After: 12" + ), + "false", + ] + ) + sleeps: list[float] = [] + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/inkspan", + run_gh=runner, + sleep=sleeps.append, + ) + == "false" + ) + assert sleeps == [12.0] + + +def test_rate_limit_reset_header_and_past_reset_are_bounded() -> None: + """Honor X-RateLimit-Reset when present; a past reset uses the default wait.""" + assert ( + visibility.parse_rate_limit_wait_seconds( + "X-RateLimit-Reset: 112", now=100.0 + ) + == 12.0 + ) + assert ( + visibility.parse_rate_limit_wait_seconds( + "X-RateLimit-Reset: 90", now=100.0 + ) + is None + ) + assert visibility.parse_rate_limit_wait_seconds("") is None + assert ( + visibility.rate_limit_backoff_seconds( + 1, + INSTALLATION_RATE_LIMIT_403 + "\nX-RateLimit-Reset: 1", + now=8.0, + ) + == 30.0 + ) + runner = _ScriptedGh( + [ + visibility.VisibilityCommandError( + INSTALLATION_RATE_LIMIT_403 + "\nX-RateLimit-Reset: 18" + ), + "false", + ] + ) + sleeps: list[float] = [] + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/inkspan", + run_gh=runner, + sleep=sleeps.append, + now=lambda: 8.0, + ) + == "false" + ) + assert sleeps == [10.0] + + +def test_rate_limit_does_not_shrink_generic_retry_budget() -> None: + """A rate-limit attempt cannot consume the separate generic retry budget.""" + runner = _ScriptedGh( + [ + visibility.VisibilityCommandError(INSTALLATION_RATE_LIMIT_403), + visibility.VisibilityCommandError("gh: HTTP 502: Bad Gateway"), + visibility.VisibilityCommandError("gh: HTTP 503: Service Unavailable"), + visibility.VisibilityCommandError("gh: HTTP 504: Gateway Timeout"), + "false", + ] + ) + sleeps: list[float] = [] + + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/inkspan", + run_gh=runner, + sleep=sleeps.append, + ) + == "false" + ) + assert sleeps == [30.0, 1.0, 2.0, 4.0] + + +def test_generic_transient_backoff_stays_short() -> None: + """A 502 flake must keep the original 1/2/4s schedule.""" + assert visibility.backoff_seconds(1) == 1.0 + assert visibility.rate_limit_backoff_seconds(1, INSTALLATION_RATE_LIMIT_403) == 30.0 + assert visibility.RATE_LIMIT_MAX_ATTEMPTS == 3 + assert visibility.DEFAULT_MAX_ATTEMPTS == 4 + + +def test_public_and_private_booleans_are_preserved() -> None: + """Exact public and private booleans must survive lookup unchanged.""" + public = _ScriptedGh(["false\n"]) + private = _ScriptedGh(["true"]) + + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/naruon", + run_gh=public, + sleep=lambda _delay: None, + ) + == "false" + ) + assert ( + visibility.fetch_repository_visibility( + "ContextualWisdomLab/xtrmLLMBatchPython", + run_gh=private, + sleep=lambda _delay: None, + ) + == "true" + ) + + +def test_zero_attempts_fail_closed_without_calling_github() -> None: + """A non-positive retry budget must not invent a visibility boolean.""" + runner = _ScriptedGh(["true"]) + + with pytest.raises( + visibility.VisibilityResolutionError, + match="at least one attempt", + ): + visibility.fetch_repository_visibility( + "ContextualWisdomLab/aFIPC", + run_gh=runner, + sleep=lambda _delay: None, + max_attempts=0, + ) + assert runner.calls == [] + + +def test_invalid_repository_fails_closed_without_calling_github() -> None: + """Targets outside ContextualWisdomLab never become a visibility probe.""" + runner = _ScriptedGh(["true"]) + + with pytest.raises( + visibility.VisibilityResolutionError, + match="must belong to ContextualWisdomLab", + ): + visibility.fetch_repository_visibility( + "octocat/Hello-World", + run_gh=runner, + sleep=lambda _delay: None, + ) + assert runner.calls == [] + + +def test_unknown_gh_failure_fails_closed_without_retry() -> None: + """Unclassified API errors are not treated as a successful public repo.""" + runner = _ScriptedGh( + [visibility.VisibilityCommandError("gh: GraphQL: Field unknown")] + ) + sleeps: list[float] = [] + + with pytest.raises( + visibility.VisibilityResolutionError, + match="Field unknown", + ): + visibility.fetch_repository_visibility( + "ContextualWisdomLab/aFIPC", + run_gh=runner, + sleep=sleeps.append, + ) + assert runner.calls == ["ContextualWisdomLab/aFIPC"] + assert sleeps == [] + + +def test_classify_gh_failure_covers_http_and_marker_families() -> None: + """HTTP status and timeout/connection markers classify independently.""" + assert visibility.classify_gh_failure("gh: HTTP 401: Bad credentials") == "permanent" + assert visibility.classify_gh_failure("HTTP/429 Too Many Requests") == "transient" + assert visibility.classify_gh_failure("gh: HTTP 500: server error") == "transient" + assert visibility.classify_gh_failure("gh: HTTP 418: teapot") == "unknown" + assert visibility.classify_gh_failure("connection reset by peer") == "transient" + assert visibility.classify_gh_failure("unexpected end of JSON input") == "transient" + assert visibility.classify_gh_failure("") == "unknown" + assert visibility.parse_private_flag(None) is None + assert visibility.backoff_seconds(4) == 4.0 + with pytest.raises( + visibility.VisibilityResolutionError, + match="must belong to ContextualWisdomLab", + ): + visibility.validate_target_repository(" ") + + +def test_run_gh_visibility_success_timeout_oserror_and_nonzero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The real ``gh api`` wrapper maps process outcomes to typed failures.""" + + def succeed(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Return a successful ``gh api`` process result.""" + assert argv == [ + "gh", + "api", + "repos/ContextualWisdomLab/aFIPC", + "--include", + "--jq", + ".private", + ] + assert kwargs["shell"] is False + return subprocess.CompletedProcess(argv, 0, stdout="false\n", stderr="") + + monkeypatch.setattr(visibility.subprocess, "run", succeed) + assert visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") == "false\n" + + def timeout(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Raise the subprocess timeout propagated by the wrapper.""" + raise subprocess.TimeoutExpired(argv, kwargs["timeout"]) + + monkeypatch.setattr(visibility.subprocess, "run", timeout) + with pytest.raises(subprocess.TimeoutExpired): + visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") + + def missing_binary( + argv: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + """Raise the missing executable error mapped to a command failure.""" + raise FileNotFoundError("gh") + + monkeypatch.setattr(visibility.subprocess, "run", missing_binary) + with pytest.raises(visibility.VisibilityCommandError, match="could not start"): + visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") + + def fail_empty(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Return a nonzero process result without diagnostic output.""" + return subprocess.CompletedProcess(argv, 2, stdout="", stderr="") + + monkeypatch.setattr(visibility.subprocess, "run", fail_empty) + with pytest.raises(visibility.VisibilityCommandError, match="exited 2"): + visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") + + def fail_token(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Return a token-bearing error that must be redacted before logging.""" + return subprocess.CompletedProcess( + argv, + 1, + stdout="", + stderr="Authorization: ghs_secretvalue123", + ) + + monkeypatch.setattr(visibility.subprocess, "run", fail_token) + with pytest.raises(visibility.VisibilityCommandError, match=""): + visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") + + def fail_with_headers(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Return response headers and stderr so both remain classifiable.""" + return subprocess.CompletedProcess( + argv, + 1, + stdout="HTTP/2 429 Too Many Requests\nRetry-After: 12\n\n", + stderr="secondary rate limit", + ) + + monkeypatch.setattr(visibility.subprocess, "run", fail_with_headers) + with pytest.raises(visibility.VisibilityCommandError, match="Retry-After: 12") as excinfo: + visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") + assert "secondary rate limit" in str(excinfo.value) + + +def test_run_gh_visibility_separates_included_headers_from_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the JSON body reaches the exact boolean parser.""" + + def included_response(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + """Return realistic ``gh api --include`` output.""" + return subprocess.CompletedProcess( + argv, + 0, + stdout="HTTP/2 200 OK\r\nX-RateLimit-Remaining: 4999\r\n\r\nfalse\n", + stderr="", + ) + + monkeypatch.setattr(visibility.subprocess, "run", included_response) + assert visibility.run_gh_visibility("ContextualWisdomLab/aFIPC") == "false\n" + + +def test_split_gh_response_handles_header_only_and_plain_output() -> None: + """The bounded splitter handles plain, complete, and incomplete responses.""" + + assert visibility.split_gh_response("false\n") == ("", "false\n") + assert visibility.split_gh_response("HTTP/2 200 OK\nHeader: value\n\ntrue\n") == ( + "HTTP/2 200 OK\nHeader: value\n\n", + "true\n", + ) + header_only = "HTTP/2 200 OK\nHeader: value\n" + assert visibility.split_gh_response(header_only) == (header_only, "") + + +def test_cli_writes_visibility_and_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The workflow entrypoint writes ``is_private`` or exits 1 fail-closed.""" + output = tmp_path / "github-output" + output.write_text("existing=1\n", encoding="utf-8") + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + monkeypatch.delenv("TARGET_REPOSITORY", raising=False) + monkeypatch.setattr( + visibility, + "fetch_repository_visibility", + lambda repository: "false" if repository.endswith("naruon") else "true", + ) + + assert ( + visibility.main( + [ + "--repository", + "ContextualWisdomLab/naruon", + "--github-output", + str(output), + ] + ) + == 0 + ) + assert output.read_text(encoding="utf-8") == "existing=1\nis_private=false\n" + + assert visibility.main(["--repository", "ContextualWisdomLab/naruon"]) == 1 + + def deny(_repository: str) -> str: + """Raise the typed visibility failure handled by the CLI.""" + raise visibility.VisibilityResolutionError("denied") + + monkeypatch.setattr(visibility, "fetch_repository_visibility", deny) + assert ( + visibility.main( + [ + "--repository", + "ContextualWisdomLab/naruon", + "--github-output", + str(output), + ] + ) + == 1 + ) + + +def test_cli_main_module_uses_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``python3 strix_resolve_target_visibility.py`` reads workflow env vars.""" + output = tmp_path / "github-output" + monkeypatch.setenv("TARGET_REPOSITORY", "ContextualWisdomLab/aFIPC") + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr(sys, "argv", ["strix_resolve_target_visibility.py"]) + + def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Return a public visibility result for the module-entrypoint test.""" + assert argv[-1] == ".private" + return subprocess.CompletedProcess(argv, 0, stdout="true\n", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(SystemExit) as excinfo: + runpy.run_path( + str(REPO_ROOT / "scripts" / "ci" / "strix_resolve_target_visibility.py"), + run_name="__main__", + ) + + assert excinfo.value.code == 0 + assert output.read_text(encoding="utf-8") == "is_private=true\n" + + +def test_workflow_step_parser_names_a_missing_step() -> None: + """A missing step name must fail with the requested name, not IndexError.""" + with pytest.raises(AssertionError, match="workflow step not found: Missing Step"): + _workflow_step("jobs:\n strix:\n steps: []\n", "Missing Step") + + +def test_strix_workflow_uses_helper_and_keeps_token_order() -> None: + """The required Strix job must call the helper with the existing token chain.""" + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + export_step = _workflow_step(workflow, "Export trusted Strix source paths") + visibility_step = _workflow_step(workflow, "Resolve target repository visibility") + + assert ( + 'test -f "$trusted_strix_source/scripts/ci/strix_resolve_target_visibility.py"' + in export_step + ) + assert ( + 'python3 "$TRUSTED_STRIX_SOURCE/scripts/ci/strix_resolve_target_visibility.py"' + in visibility_step + ) + assert ( + "GH_TOKEN: ${{ steps.target_app_token.outputs.token || " + "secrets.OPENCODE_APPROVE_TOKEN || github.token }}" + ) in visibility_step + assert "COPILOT_GITHUB_TOKEN" not in visibility_step + assert "COPILOT_GITHUB_TOKEN" not in workflow + assert 'is_private="$(gh api "repos/${TARGET_REPOSITORY}" --jq \'.private\')"' not in ( + workflow + ) + assert "STRIX_SCAN_MODE" not in visibility_step + + +def test_noema_and_opencode_visibility_paths_stay_untouched() -> None: + """This slice must not change Noema or OpenCode review workflows.""" + noema = NOEMA_WORKFLOW.read_text(encoding="utf-8") + opencode = ( + REPO_ROOT / ".github" / "workflows" / "opencode-review.yml" + ).read_text(encoding="utf-8") + + assert 'is_private="$(gh api "repos/${TARGET_REPOSITORY}" --jq \'.private\')"' in ( + noema + ) + assert "strix_resolve_target_visibility.py" not in noema + assert "strix_resolve_target_visibility.py" not in opencode