Introduce a deterministic (no-LLM) coverage sub-gate that blocks coverage degradation before a task branch merges into `main`. Existing gates judge only by the FACT of passing (check_ci_green / check_tests_passed / merge-gate re-test), not by completeness — so a batch autonomous run (ORCH-088) silently erodes coverage. Pattern mirrors the security-gate (ORCH-022): leaf src/coverage_gate.py (never-raise) + thin check_coverage_gate in QG_CHECKS + _handle_coverage_gate splice in advance_stage, run AFTER merge-gate (measured on the caught-up HEAD that lands in main) and BEFORE image-freshness (fail before the expensive docker rebuild). - measure_coverage: pytest --cov=src --cov-report=json in the per-branch worktree -> line coverage %; None on tool error -> fail-open + WARNING by default (FR-6). - compute_coverage_verdict (pure): absolute | baseline | both + epsilon (NFR-4 anti-flap); baseline None -> bootstrap (absolute-only). - coverage_baseline DB table (additive, CREATE TABLE IF NOT EXISTS) + ratchet-up in _handle_merge_verify (deploy->done): atomic compare-and-set under merge-lease, never decreases; bootstrap on first merge. - Artefact 18-coverage-report.md (coverage_status: frontmatter, single source of truth); GET /queue `coverage` block; FAIL -> Telegram; optional POST /coverage/baseline override. - Flags ORCH_COVERAGE_* (kill-switch + self-hosting-only scope) -> enduro untouched; STAGE_TRANSITIONS / existing check_* / verdict keys byte-for-byte unchanged (NFR-5/AC-8). - pytest-cov==5.0.0 added to requirements.txt. Tests: tests/test_coverage_gate.py (TC-01..TC-15). Frozen QG-registry anti-regress tests + deploy-staging edge tests updated for the new sub-gate. Full suite green. Docs: README / adr-0029 / PIPELINE_DOCS / 18-coverage-report.md template (architecture stage) + CHANGELOG / CLAUDE.md / .env.example (this PR). Refs: ORCH-027 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
7.3 KiB
Python
156 lines
7.3 KiB
Python
"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage.
|
|
|
|
These tests pin the layer-B behaviour WITHOUT touching layer A (the stage
|
|
machine). httpx is mocked; no network.
|
|
|
|
* TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress.
|
|
* TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`.
|
|
* TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input).
|
|
* TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin).
|
|
* TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged.
|
|
"""
|
|
|
|
import os
|
|
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
|
|
from unittest.mock import patch, MagicMock # noqa: E402
|
|
|
|
from src import plane_sync as PS # noqa: E402
|
|
|
|
|
|
# A per-project state map that DEFINES the new ORCH-066 statuses with distinct
|
|
# UUIDs, so we can prove the dedicated status (not the base alias) is used.
|
|
_STATES_WITH_NEW = {
|
|
"in_progress": "ip-uuid",
|
|
"review": "review-uuid",
|
|
"in_review": "inrev-uuid",
|
|
"needs_input": "ni-uuid",
|
|
"done": "done-uuid",
|
|
"analysis": "analysis-uuid",
|
|
"code_review": "codereview-uuid",
|
|
"awaiting_deploy": "awaiting-uuid",
|
|
"deploying": "deploying-uuid",
|
|
"monitoring": "monitoring-uuid",
|
|
}
|
|
|
|
|
|
def _patch_resolve(states):
|
|
"""Patch find_issue_id + _resolve_project_id + get_project_states so a
|
|
set_issue_* helper reaches the PATCH with a known per-project state map."""
|
|
return (
|
|
patch("src.plane_sync.httpx.patch"),
|
|
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"),
|
|
patch("src.plane_sync._resolve_project_id", return_value="proj-1"),
|
|
patch("src.plane_sync.get_project_states", return_value=states),
|
|
)
|
|
|
|
|
|
def _run_setter(setter, states):
|
|
p_patch, p_find, p_res, p_states = _patch_resolve(states)
|
|
with p_patch as mock_patch, p_find, p_res, p_states:
|
|
resp = MagicMock()
|
|
resp.raise_for_status.return_value = None
|
|
mock_patch.return_value = resp
|
|
setter("ET-1")
|
|
return mock_patch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-03 (AC-3): analyst start/resume indicates Analysis.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc03_set_issue_analysis_patches_analysis_uuid():
|
|
mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW)
|
|
# The dedicated Analysis UUID is used (NOT the in_progress base alias).
|
|
assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid"
|
|
assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"]
|
|
|
|
|
|
def test_tc03_analysis_aliases_in_progress_when_absent():
|
|
# A project without the Analysis status -> get_project_states already aliased
|
|
# 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully.
|
|
aliased = dict(_STATES_WITH_NEW)
|
|
aliased["analysis"] = aliased["in_progress"]
|
|
mock_patch = _run_setter(PS.set_issue_analysis, aliased)
|
|
assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-05 (AC-5): the review stage indicates Code-Review.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc05_review_stage_maps_to_code_review():
|
|
# Both the stage->state-key map and the stage-visibility map point review at
|
|
# the new code_review logical key (layer B only).
|
|
assert PS._STAGE_TO_STATE_KEY["review"] == "code_review"
|
|
assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review"
|
|
|
|
|
|
def test_tc05_set_issue_stage_state_review_patches_code_review_uuid():
|
|
p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW)
|
|
with p_patch as mock_patch, p_find, p_res, p_states:
|
|
resp = MagicMock()
|
|
resp.raise_for_status.return_value = None
|
|
mock_patch.return_value = resp
|
|
PS.set_issue_stage_state("ET-1", "review")
|
|
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
|
|
|
|
|
|
def test_tc05_set_issue_code_review_helper_patches_code_review_uuid():
|
|
mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW)
|
|
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-14 (AC-14): Needs Input behaviour unchanged.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc14_needs_input_unchanged():
|
|
mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW)
|
|
assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes
|
|
# ONLY layer B — the machine must not move.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc22_stage_transitions_unchanged():
|
|
from src.stages import STAGE_TRANSITIONS
|
|
assert STAGE_TRANSITIONS == {
|
|
"created": {"next": "analysis", "agent": "analyst", "qg": None},
|
|
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
|
|
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
|
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
|
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
|
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
|
|
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
|
|
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
|
"done": {"next": None, "agent": None, "qg": None},
|
|
# ORCH-090 (adr-0026): terminal SINK for a STOP-cancelled task.
|
|
"cancelled": {"next": None, "agent": None, "qg": None},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc23_qg_checks_registry_unchanged():
|
|
from src.qg.checks import QG_CHECKS
|
|
assert set(QG_CHECKS.keys()) == {
|
|
"check_analysis_approved", "check_analysis_complete", "check_architecture_done",
|
|
"check_ci_green", "check_review_approved", "check_tests_passed",
|
|
"check_reviewer_verdict", "check_tests_local", "check_deploy_status",
|
|
"check_staging_status", "check_branch_mergeable", "check_staging_image_fresh",
|
|
"check_security_gate", # ORCH-022 integ: security-gate registered
|
|
"check_coverage_gate", # ORCH-027 integ: coverage-gate registered
|
|
}
|
|
|
|
|
|
def test_tc23_check_deploy_status_signature_unchanged():
|
|
import inspect
|
|
from src.qg.checks import check_deploy_status, QG_CHECKS
|
|
# Registry still points at the same callable.
|
|
assert QG_CHECKS["check_deploy_status"] is check_deploy_status
|
|
# (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact.
|
|
params = list(inspect.signature(check_deploy_status).parameters)
|
|
assert params == ["repo", "work_item_id", "branch"]
|