From 4e4cc6c724b85726ca48360bff30d091180fc42f Mon Sep 17 00:00:00 2001 From: dev-agent Date: Thu, 4 Jun 2026 13:35:35 +0300 Subject: [PATCH] fix(qg): find 14-deploy-log.md in origin/main when absent in feature worktree ET-013: deployer writes 14-deploy-log.md and merges deploy artifacts into main via a separate PR, so the log lands in origin/main, not the feature branch worktree that check_deploy_status reads via _repo_path(repo, branch). Result: every successful deploy was falsely failed (Deploy log not found) and rolled back deploy->development. Fix: when the log is absent in the worktree, fall back to reading it from origin/main on the shared clone (git fetch origin main + git show origin/main:docs/work-items//14-deploy-log.md). Lookup order: worktree -> origin/main -> not found. Fetch/show failures degrade to not found (never raise). Does not touch the merge-gate in gitea.py. Tests: origin/main SUCCESS->PASS (ET-013 case), origin/main FAILED->FAILED, absent everywhere->not found, fetch failure->degrades no exception, worktree log short-circuits main lookup. --- src/qg/checks.py | 101 ++++++++++++++++++++++++++++++++++++----------- tests/test_qg.py | 59 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/src/qg/checks.py b/src/qg/checks.py index 3a9661f..1f07c9d 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -2,6 +2,7 @@ import os import logging +import subprocess import httpx from ..config import settings @@ -281,6 +282,64 @@ def check_tests_local(repo: str, branch: str) -> tuple[bool, str]: return False, f"Local test run error: {e}" +def _parse_deploy_status(content: str) -> tuple[bool, str]: + """Parse a 14-deploy-log.md body and map its `deploy_status:` frontmatter to a + quality-gate verdict. Reads ONLY the machine-readable YAML field, never prose. + + deploy_status: SUCCESS -> (True, "Deploy status: SUCCESS") + deploy_status: FAILED -> (False, "Deploy status: FAILED") + missing field / no frontmatter / bad YAML -> (False, ) + """ + import yaml + status = None + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + try: + fm = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError as e: + return False, f"Invalid YAML frontmatter in deploy log: {e}" + status = str(fm.get("deploy_status", "")).upper().strip() + if status == "SUCCESS": + return True, "Deploy status: SUCCESS" + if status == "FAILED": + return False, "Deploy status: FAILED" + return False, f"No machine-readable deploy_status in frontmatter (got: {status!r})" + + +def _deploy_log_from_main(repo: str, work_item_id: str) -> str | None: + """Best-effort read of 14-deploy-log.md from origin/main on the shared clone. + + The deployer writes 14-deploy-log.md and merges the deploy artifacts into main + via a separate PR (see ET-013), so the file lands in origin/main, NOT in the + feature branch worktree the gate normally reads. This recovers it from main. + + Degrades gracefully: any git failure (no clone, network/fetch error, file + absent in main) returns None instead of raising, so the caller falls back to + the plain "not found" verdict. Never raises. + """ + repo_clone = os.path.join(settings.repos_dir, repo) + if not os.path.isdir(os.path.join(repo_clone, ".git")): + return None + rel = f"docs/work-items/{work_item_id}/14-deploy-log.md" + try: + # Refresh origin/main so we see freshly-merged deploy artifacts. + subprocess.run( + ["git", "-C", repo_clone, "fetch", "origin", "main"], + check=False, capture_output=True, timeout=30, + ) + show = subprocess.run( + ["git", "-C", repo_clone, "show", f"origin/main:{rel}"], + check=False, capture_output=True, text=True, timeout=15, + ) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("deploy-log origin/main lookup failed for %s/%s: %s", repo, work_item_id, e) + return None + if show.returncode != 0: + return None + return show.stdout + + def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]: """ БАГ 8 fix: gate the deploy -> done transition on the deployer's machine-readable @@ -291,32 +350,30 @@ def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None) frontmatter. Returns: (True, ...) -> deploy_status: SUCCESS (False, ...) -> deploy_status: FAILED, missing field, or no frontmatter + + ET-013 path-sync fix: the deployer writes 14-deploy-log.md and merges the deploy + artifacts into main via a SEPARATE PR, so the log lands in origin/main, not in + the feature-branch worktree this gate reads via _repo_path(repo, branch). If the + file is absent in the worktree we fall back to reading it from origin/main on the + shared clone. Lookup order: worktree -> origin/main -> not found. """ - import yaml repo_path = _repo_path(repo, branch) log_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/14-deploy-log.md") - if not os.path.isfile(log_path): - return False, "Deploy log not found (14-deploy-log.md)" - try: - with open(log_path, "r") as f: - content = f.read() - status = None - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - try: - fm = yaml.safe_load(parts[1]) or {} - except yaml.YAMLError as e: - return False, f"Invalid YAML frontmatter in deploy log: {e}" - status = str(fm.get("deploy_status", "")).upper().strip() - if status == "SUCCESS": - return True, "Deploy status: SUCCESS" - if status == "FAILED": - return False, "Deploy status: FAILED" - return False, f"No machine-readable deploy_status in frontmatter (got: {status!r})" - except OSError as e: - return False, f"Error reading deploy log: {e}" + if os.path.isfile(log_path): + try: + with open(log_path, "r") as f: + content = f.read() + except OSError as e: + return False, f"Error reading deploy log: {e}" + return _parse_deploy_status(content) + + # Not in the feature worktree — the deployer may have merged it into main. + main_content = _deploy_log_from_main(repo, work_item_id) + if main_content is not None: + return _parse_deploy_status(main_content) + + return False, "Deploy log not found (14-deploy-log.md)" # Registry for dynamic lookup by name diff --git a/tests/test_qg.py b/tests/test_qg.py index c650ec9..1c0ef96 100644 --- a/tests/test_qg.py +++ b/tests/test_qg.py @@ -242,6 +242,65 @@ class TestCheckDeployStatus: passed, reason = check_deploy_status("enduro-trails", "ET-011") assert passed is False + # --- ET-013 path-sync fix: log written to origin/main via separate PR --- + + def test_origin_main_success_passes_when_absent_in_worktree(self, monkeypatch): + # Deployer merged 14-deploy-log.md into main via a separate PR; it is NOT + # in the feature worktree. Gate must recover it from origin/main -> PASS. + # (This is the exact ET-013 regression.) + monkeypatch.setattr( + "src.qg.checks._deploy_log_from_main", + lambda repo, wi: "---\ndeploy_status: SUCCESS\nversion: v0.0.5\n---\n\nLive.\n", + ) + passed, reason = check_deploy_status("enduro-trails", "ET-013") + assert passed is True + assert "SUCCESS" in reason + + def test_origin_main_failed_fails(self, monkeypatch): + # A genuine FAILED log in main must still fail. + monkeypatch.setattr( + "src.qg.checks._deploy_log_from_main", + lambda repo, wi: "---\ndeploy_status: FAILED\nversion: v0.0.5\n---\n\nboom.\n", + ) + passed, reason = check_deploy_status("enduro-trails", "ET-013") + assert passed is False + assert "FAILED" in reason + + def test_absent_everywhere_fails(self, monkeypatch): + # Not in worktree and origin/main lookup yields nothing -> not found. + monkeypatch.setattr( + "src.qg.checks._deploy_log_from_main", lambda repo, wi: None + ) + passed, reason = check_deploy_status("enduro-trails", "ET-013") + assert passed is False + assert "not found" in reason.lower() + + @patch("src.qg.checks.subprocess.run") + @patch("src.qg.checks.os.path.isdir", return_value=True) + def test_fetch_failure_degrades_no_exception(self, mock_isdir, mock_run): + # git fetch/show raising (e.g. network) must degrade to "not found", + # never propagate an exception out of the gate. + import subprocess as _sp + mock_run.side_effect = _sp.TimeoutExpired(cmd="git", timeout=30) + passed, reason = check_deploy_status("enduro-trails", "ET-013") + assert passed is False + assert "not found" in reason.lower() + + def test_worktree_log_short_circuits_main_lookup(self, setup_work_item_dir, monkeypatch): + # If the log IS present in the worktree, origin/main must NOT be consulted. + self._write_log( + setup_work_item_dir, + "---\ndeploy_status: SUCCESS\nversion: v0.0.3\n---\n\nDeployed OK.\n", + ) + called = {"n": 0} + def _boom(repo, wi): + called["n"] += 1 + return None + monkeypatch.setattr("src.qg.checks._deploy_log_from_main", _boom) + passed, reason = check_deploy_status("enduro-trails", "ET-011") + assert passed is True + assert called["n"] == 0 + def test_deploy_stage_qg_is_check_deploy_status(self): assert get_qg_for_stage("deploy") == "check_deploy_status"