"""ORCH-053 (F-3): sha->branch resolution hardening in handle_ci_status. When a CI-status webhook carries no ``branches[]`` and the SHA cannot be resolved to a feature branch via ``git branch -r --contains`` (lost on a 502 rebuild, shallow clone, etc.), handle_ci_status now falls back to the tasks DB and matches the UNIQUE development-stage task of the repo. Ambiguity (more than one development task) is deliberately left unresolved so it can never make a false match. The git subprocess and the network QG / Plane / Telegram side effects are mocked so the handler runs offline against a real isolated sqlite DB. """ import asyncio import os import tempfile from types import SimpleNamespace import pytest _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_gitea_sha.db") os.environ["ORCH_DB_PATH"] = _test_db os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") from unittest.mock import MagicMock # noqa: E402 import src.db as _db # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src.webhooks import gitea as gitea_mod # noqa: E402 @pytest.fixture(autouse=True) def fresh_db(monkeypatch): monkeypatch.setattr(_db.settings, "db_path", _test_db) if os.path.exists(_test_db): os.unlink(_test_db) init_db() yield @pytest.fixture(autouse=True) def silence_and_stub_git(monkeypatch): # git branch -r --contains resolves to nothing (forces the DB fallback). monkeypatch.setattr( gitea_mod.subprocess, "run", lambda *a, **k: SimpleNamespace(stdout="", returncode=0), ) # Mute the network side effects bound module-level in gitea. for name in ("notify_stage_change", "notify_qg_failure", "notify_error", "plane_notify_stage"): monkeypatch.setattr(gitea_mod, name, MagicMock(), raising=False) def _make_dev_task(branch, wi, repo="enduro-trails"): conn = get_db() cur = conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " "VALUES (?, ?, ?, ?, 'development')", (f"plane-{wi}", wi, repo, branch), ) tid = cur.lastrowid conn.commit() conn.close() return tid def _stage_of(task_id): conn = get_db() row = conn.execute("SELECT stage FROM tasks WHERE id = ?", (task_id,)).fetchone() conn.close() return row["stage"] def _ci_payload(sha="deadbeef", repo="enduro-trails", state="success"): return { "state": state, "sha": sha, "branches": [], # no branch in the event -> forces resolution "repository": {"name": repo}, } # --------------------------------------------------------------------------- # TC-18: unique development task -> DB fallback resolves the branch, advances. # --------------------------------------------------------------------------- def test_tc18_db_fallback_unique_match_advances(monkeypatch): ci = MagicMock(return_value=(True, "CI green")) monkeypatch.setattr(gitea_mod, "check_ci_green", ci) tid = _make_dev_task("feature/ET-050-x", "ET-050") asyncio.run(gitea_mod.handle_ci_status(_ci_payload())) assert _stage_of(tid) == "review" ci.assert_called_once() # The fallback resolved to the unique dev task's branch. assert ci.call_args.args[1] == "feature/ET-050-x" # --------------------------------------------------------------------------- # TC-19: several development tasks -> ambiguous -> no false match, no advance. # --------------------------------------------------------------------------- def test_tc19_db_fallback_ambiguous_no_match(monkeypatch, caplog): ci = MagicMock(return_value=(True, "CI green")) monkeypatch.setattr(gitea_mod, "check_ci_green", ci) t1 = _make_dev_task("feature/ET-051-a", "ET-051") t2 = _make_dev_task("feature/ET-052-b", "ET-052") with caplog.at_level("INFO", logger="orchestrator.webhooks.gitea"): asyncio.run(gitea_mod.handle_ci_status(_ci_payload())) # Ambiguity -> branch unresolved -> handler returns before touching the gate. assert _stage_of(t1) == "development" assert _stage_of(t2) == "development" ci.assert_not_called() assert "could not determine branch" in caplog.text