"""Tests for ``check_ci`` in .opencode/scripts/pipeline-status.py — CI phase. Covers CI gate via ``gh pr view --json statusCheckRollup`` (aggregates ALL checks on PR head SHA — CI, CI (always), ADR check, etc.). All gh/git calls are mocked via monkeypatch on the module's ``run_cmd`` helper. ``time.sleep`` is mocked to no-op via autouse fixture (polling loop would otherwise hang tests for up to 5 minutes). Mocking strategy: ``check_ci`` issues calls in sequence: 1. ``git remote get-url origin`` → returns remote URL (for ``get_repo_full_name``) 2. ``gh pr view N --json statusCheckRollup`` → returns rollup JSON; may be called multiple times (polling loop / no-checks retries). ``mock_run_cmd`` dispatches by command prefix (single response per prefix). ``mock_run_cmd_seq`` supports a sequence of responses for the ``gh pr view`` prefix (consumed in order; last repeats if exhausted) — for polling tests. ``get_repo_full_name`` is cached via ``functools.cache`` — cleared before each test via the ``_clear_repo_cache`` autouse fixture. """ import importlib.util import sys from pathlib import Path import pytest SCRIPT_PATH = ( Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py" ) spec = importlib.util.spec_from_file_location("pipeline_status_ci", SCRIPT_PATH) ps = importlib.util.module_from_spec(spec) sys.modules["pipeline_status_ci"] = ps spec.loader.exec_module(ps) GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = ( ("git", "remote"), (0, "https://github.com/slaid098/opencode-config.git\n", ""), ) @pytest.fixture(autouse=True) def _clear_repo_cache(): """Clear ``functools.cache`` on ``get_repo_full_name`` + mock ``time.sleep``. ``time.sleep`` must be mocked — polling loop sleeps ``CI_POLL_INTERVAL`` between queries, up to ``CI_WAIT_TIMEOUT`` (default 300s). Without mock, tests polling until timeout would hang for minutes. """ ps.get_repo_full_name.cache_clear() sleep_calls: list[float] = [] monkey = pytest.MonkeyPatch() def _record_sleep(seconds: float) -> None: sleep_calls.append(seconds) monkey.setattr(ps.time, "sleep", _record_sleep) ps._test_sleep_calls = sleep_calls # type: ignore[attr-defined] yield monkey.undo() ps.get_repo_full_name.cache_clear() del ps._test_sleep_calls # type: ignore[attr-defined] def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]): """Factory: mock run_cmd matching by command prefix (single response). Keys are tuples of leading args (e.g. ("gh", "pr", "view")), values are (returncode, stdout, stderr). Automatically includes a ``git remote get-url origin`` mock (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra boilerplate in every test. """ merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses} def _mock(args: list[str]) -> tuple[int, str, str]: for prefix, result in merged.items(): if tuple(args[: len(prefix)]) == tuple(prefix): return result return (1, "", f"unmocked call: {args}") return _mock def mock_run_cmd_seq( pr_view_responses: list[tuple[int, str, str]] | None = None, extra: dict[tuple, tuple[int, str, str]] | None = None, ): """Factory: mock run_cmd with sequence of responses for ``gh pr view``. ``gh pr view`` returns ``pr_view_responses[i]`` on the i-th call; if exhausted, repeats the last response (so polling loops don't run out of responses and hit the "unmocked" fallback). ``extra`` adds fixed overrides for other prefixes (e.g. ``git remote``). """ pr_view_responses = pr_view_responses or [] view_idx = [0] merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **(extra or {})} def _mock(args: list[str]) -> tuple[int, str, str]: for prefix, result in merged.items(): if tuple(args[: len(prefix)]) == tuple(prefix): return result if tuple(args[:3]) == ("gh", "pr", "view"): if not pr_view_responses: return (1, "", "no pr_view_responses configured") idx = min(view_idx[0], len(pr_view_responses) - 1) view_idx[0] += 1 return pr_view_responses[idx] return (1, "", f"unmocked call: {args}") return _mock # ── check_ci — completed (no polling) ────────────────────────────────────────── def test_ci_success(monkeypatch): """Rollup: 2 checks COMPLETED+SUCCESS → DONE, 0 sleeps.""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "pr", "view"): ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, ' '{"name": "ADR check", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ), } ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.DONE assert "CI green" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] def test_ci_failure(monkeypatch): """Rollup: 1 check COMPLETED+FAILURE → NOT_DONE, 0 sleeps.""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "pr", "view"): ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}' "]}", "", ), } ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.NOT_DONE assert "failure" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] def test_ci_docs_only_pr(monkeypatch): """Docs-only PR: CI (always) + ADR check, both SUCCESS → DONE. Key test for PR#120 bug: docs-only PRs don't trigger ``ci.yml`` (paths-ignore), only ``always-ci.yml``. Old code filtered by ``name=="CI"`` → missed → AMBIGUOUS. statusCheckRollup aggregates all. """ monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "pr", "view"): ( 0, '{"statusCheckRollup": [' '{"name": "CI (always)", "status": "COMPLETED", "conclusion": "SUCCESS"}, ' '{"name": "ADR Refs", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ), } ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.DONE assert "CI green" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] # ── check_ci — polling loop ──────────────────────────────────────────────────── def test_ci_wait_then_success(monkeypatch): """IN_PROGRESS → IN_PROGRESS → COMPLETED+SUCCESS → DONE, sleep called 2 times.""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.DONE assert "CI green" in result.detail assert len(ps._test_sleep_calls) == 2 # type: ignore[attr-defined] def test_ci_wait_timeout(monkeypatch): """All calls IN_PROGRESS, timeout 1s via env → AMBIGUOUS with "после 1s".""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "после 1s" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] def test_ci_wait_poll_interval(monkeypatch): """Poll interval is used as sleep argument — capture via mock.""" monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "7") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.DONE assert ps._test_sleep_calls == [7] # type: ignore[attr-defined] def test_ci_in_progress(monkeypatch): """IN_PROGRESS, all polls IN_PROGRESS, timeout 1s → AMBIGUOUS.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "после 1s" in result.detail def test_ci_queued(monkeypatch): """QUEUED, all polls IN_PROGRESS, timeout 1s → AMBIGUOUS.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "QUEUED", "conclusion": null}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "после 1s" in result.detail # ── check_ci — no checks (short retry) ───────────────────────────────────────── def test_ci_no_checks_retry_then_appears(monkeypatch): """1st empty, 2nd empty, 3rd SUCCESS → DONE (after CI_NO_RUNS_RETRY retries).""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ (0, '{"statusCheckRollup": []}', ""), (0, '{"statusCheckRollup": []}', ""), ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.DONE assert "CI green" in result.detail def test_ci_no_checks_retry_exhausted(monkeypatch): """All calls empty, CI_NO_RUNS_RETRY=3 → AMBIGUOUS.""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ (0, '{"statusCheckRollup": []}', ""), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "нет CI checks" in result.detail def test_ci_no_checks(monkeypatch): """Empty rollup with single response → AMBIGUOUS (retry exhausted).""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "pr", "view"): (0, '{"statusCheckRollup": []}', ""), } ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "нет CI checks" in result.detail # ── check_ci — API error (no retry) ──────────────────────────────────────────── def test_ci_api_error(monkeypatch): """gh pr view returns rc=1 (403) → AMBIGUOUS, no retries/sleeps.""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "pr", "view"): ( 1, "", "HTTP 403: Forbidden", ), } ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "PR API error" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] def test_ci_api_error_no_retry(monkeypatch): """1st call rc=1 (403) → сразу AMBIGUOUS, 0 sleeps/retries.""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ (1, "", "HTTP 403: Forbidden"), ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "PR API error" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] # ── check_ci — partial status (multiple checks) ─────────────────────────────── def test_ci_partial_failure(monkeypatch): """1 SUCCESS + 1 FAILURE → NOT_DONE (any failure blocks).""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd( { ("gh", "pr", "view"): ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, ' '{"name": "ADR check", "status": "COMPLETED", "conclusion": "FAILURE"}' "]}", "", ), } ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.NOT_DONE assert "failure" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] def test_ci_partial_in_progress(monkeypatch): """1 COMPLETED+SUCCESS + 1 IN_PROGRESS → AMBIGUOUS (poll).""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, ' '{"name": "ADR check", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "после 1s" in result.detail # ── check_ci — config priority (CLI > env > constant) ────────────────────────── def test_ci_wait_env_var_override(monkeypatch): """OPENCODE_CI_WAIT_TIMEOUT=1 → timeout 1s instead of default 300s.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "после 1s" in result.detail def test_ci_wait_cli_flag_overrides_env(monkeypatch): """CLI flag --ci-wait-timeout 2 + env OPENCODE_CI_WAIT_TIMEOUT=1 → timeout 2s.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") def _fixed_config(argv=None): return ps.CiPollConfig(wait_timeout=2, poll_interval=ps.CI_POLL_INTERVAL) monkeypatch.setattr(ps, "_load_ci_config", _fixed_config) monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.AMBIGUOUS assert "после 2s" in result.detail def test_ci_wait_failure_no_wait(monkeypatch): """1st call COMPLETED+FAILURE → сразу NOT_DONE, 0 sleeps (don't wait for fail).""" monkeypatch.setattr( ps, "run_cmd", mock_run_cmd_seq( pr_view_responses=[ ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}' "]}", "", ), ], ), ) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.NOT_DONE assert "failure" in result.detail assert ps._test_sleep_calls == [] # type: ignore[attr-defined] # ── check_ci — dynamic repo full name ────────────────────────────────────────── def test_ci_uses_dynamic_repo_full_name(monkeypatch): """``get_repo_full_name`` is derived from ``git remote``, not hardcoded. Mocks a non-opencode remote (``slaid098/media-gen``) and asserts that the ``gh pr view`` call uses ``--repo slaid098/media-gen`` (dynamic), not a hardcoded ``slaid098/opencode-config``. """ captured_args: list[list[str]] = [] def _capture_mock(args: list[str]) -> tuple[int, str, str]: captured_args.append(args) if tuple(args[:3]) == ("git", "remote", "get-url"): return (0, "https://github.com/slaid098/media-gen.git\n", "") if tuple(args[:3]) == ("gh", "pr", "view"): return ( 0, '{"statusCheckRollup": [' '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}' "]}", "", ) return (1, "", f"unmocked call: {args}") monkeypatch.setattr(ps, "run_cmd", _capture_mock) result = ps.check_ci(46) assert result.status == ps.PhaseStatus.DONE pr_view_calls = [a for a in captured_args if a[:3] == ["gh", "pr", "view"]] assert len(pr_view_calls) == 1 assert "--repo" in pr_view_calls[0] assert "slaid098/media-gen" in " ".join(pr_view_calls[0]) assert "slaid098/opencode-config" not in " ".join(pr_view_calls[0]) # ── _load_ci_config — priority ──────────────────────────────────────────────── def test_load_ci_config_defaults(monkeypatch): """No env, no CLI → constants.""" monkeypatch.delenv("OPENCODE_CI_WAIT_TIMEOUT", raising=False) monkeypatch.delenv("OPENCODE_CI_POLL_INTERVAL", raising=False) cfg = ps._load_ci_config(argv=[]) assert cfg.wait_timeout == ps.CI_WAIT_TIMEOUT assert cfg.poll_interval == ps.CI_POLL_INTERVAL def test_load_ci_config_env_override(monkeypatch): """Env vars override constants.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "42") monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "7") cfg = ps._load_ci_config(argv=[]) assert cfg.wait_timeout == 42 assert cfg.poll_interval == 7 def test_load_ci_config_cli_overrides_env(monkeypatch): """CLI flags override env vars.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "1") cfg = ps._load_ci_config(argv=["--ci-wait-timeout", "2", "--ci-poll-interval", "3"]) assert cfg.wait_timeout == 2 assert cfg.poll_interval == 3 def test_load_ci_config_env_invalid_falls_back(monkeypatch): """Invalid env var value → fall back to constant.""" monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "not-a-number") cfg = ps._load_ci_config(argv=[]) assert cfg.wait_timeout == ps.CI_WAIT_TIMEOUT if __name__ == "__main__": pytest.main([__file__, "-v"])