Files
orchestrator/tests/test_labels.py
claude-bot a6d0ba51c0 feat(labels): auto-mode by Plane labels — autoApprove + autoDeploy (ORCH-089)
Lift the two HUMAN gates that block an autonomous batch run (epic ORCH-088):
the BRD gate (analysis: manual Approved) and the prod-deploy gate (deploy
Phase A: manual Confirm Deploy, ORCH-059). Selective (a Plane label on the
issue), declarative, reversible, and WITHOUT touching a single technical check.

Additive, mirroring the conditional sub-gates (ORCH-035/043/058/088): leaf
src/labels.py (never-raise) + two point insertions + config flags.
STAGE_TRANSITIONS / QG_CHECKS / check_* / DB schema are NOT touched.

- autoApprove: врезка in _handle_analysis_approved_flow (files_ok branch) ->
  set_issue_approved + log/Telegram/Plane-comment + advance_stage(
  finished_agent=None) — the SAME path a human Approved takes (approved-via-
  status -> analysis->architecture + mark_brd_review_ended). No duplicated
  transition logic; re-entrancy safe.
- autoDeploy: врезка in _handle_self_deploy_phase_a after advance to deploy +
  clear_state -> log/Telegram/Plane-comment + _handle_self_deploy_phase_b
  (INITIATED marker, Deploying, finalizer). Only the indicative human steps are
  skipped. BR-5 holds structurally: Phase A is reached only after the green edge
  sub-gates, so autoDeploy can never deploy a broken build.
- plane_sync: fetch_issue_labels (None on error != []), get_project_labels
  ({normalized_name->uuid}, TTL cache, ambiguity sentinel), set_issue_approved.
- config flags: auto_label_enabled (kill-switch), auto_approve_label/
  auto_deploy_label, auto_label_repos (empty -> self-hosting only),
  auto_label_states_ttl_s. applies() (local) checked FIRST; has_label (network)
  only when applies==True -> zero network / zero regression when disabled (AC-8).
- Fail-safe (never auto on doubt), transparency via log+Telegram+Plane+card,
  read-only auto_labels block in GET /queue.
- Tests TC-01..TC-26 across 7 modules; docs (CLAUDE.md, architecture README,
  CHANGELOG) updated in the same PR.

Refs: ORCH-089

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:31:24 +03:00

151 lines
6.8 KiB
Python

"""ORCH-089 — src/labels.py: auto-mode pure logic (never-raise, fail-safe).
Covers (04-test-plan.yaml):
TC-01 has_label True when the label is present on the issue.
TC-02 has_label False when the label is absent.
TC-03 has_label fail-safe (no auto, never-raise) on Plane API error/timeout.
TC-04 label-name matching is normalized (case/space); ambiguity -> no auto.
TC-05 auto_approve_applies / auto_deploy_applies: CSV scope + self-hosting.
TC-06 auto_label_enabled=False -> applies() False -> has_label never reached
(no network call on the gate).
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_labels.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import labels # noqa: E402
from src import plane_sync # noqa: E402
from src import config as cfg # noqa: E402
@pytest.fixture(autouse=True)
def enabled_self_hosting(monkeypatch):
monkeypatch.setattr(cfg.settings, "auto_label_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "auto_approve_label", "autoApprove", raising=False)
monkeypatch.setattr(cfg.settings, "auto_deploy_label", "autoDeploy", raising=False)
monkeypatch.setattr(cfg.settings, "auto_label_repos", "", raising=False)
# Keep _resolve_project_id offline-deterministic.
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1")
yield
# --- TC-01 / TC-02 ---------------------------------------------------------
def test_tc01_has_label_present(monkeypatch):
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
assert labels.has_label("ORCH-1", "autoApprove") is True
def test_tc02_has_label_absent(monkeypatch):
# Issue carries a different label uuid than the project's autoApprove uuid.
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-OTHER"])
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
assert labels.has_label("ORCH-1", "autoApprove") is False
def test_tc02_has_label_empty_issue_labels(monkeypatch):
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: [])
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
assert labels.has_label("ORCH-1", "autoApprove") is False
# --- TC-03: fail-safe / never-raise ----------------------------------------
def test_tc03_fetch_none_failsafe(monkeypatch):
# fetch returns None (could-not-read) -> False, no auto.
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: None)
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
assert labels.has_label("ORCH-1", "autoApprove") is False
def test_tc03_fetch_raises_failsafe(monkeypatch):
def boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync, "fetch_issue_labels", boom)
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
# Never raises; degrades to no auto.
assert labels.has_label("ORCH-1", "autoApprove") is False
def test_tc03_project_map_empty_failsafe(monkeypatch):
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {})
assert labels.has_label("ORCH-1", "autoApprove") is False
# --- TC-04: normalization + ambiguity --------------------------------------
def test_tc04_normalized_match(monkeypatch):
# Issue label uuid-A; project maps a differently-cased/spaced name to it.
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
# Sought name has different case + surrounding spaces.
assert labels.has_label("ORCH-1", " AUTOapprove ") is True
def test_tc04_ambiguous_name_no_auto(monkeypatch):
# Two distinct project labels collapse to the same normalized name -> ambiguous
# sentinel from get_project_labels -> has_label False.
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
monkeypatch.setattr(
plane_sync, "get_project_labels",
lambda pid: {"autoapprove": "__AMBIGUOUS__"},
)
assert labels.has_label("ORCH-1", "autoApprove") is False
def test_tc04_empty_label_name(monkeypatch):
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
assert labels.has_label("ORCH-1", "") is False
# --- TC-05: scope (CSV + self-hosting) -------------------------------------
def test_tc05_empty_csv_self_hosting_only(monkeypatch):
monkeypatch.setattr(cfg.settings, "auto_label_repos", "", raising=False)
assert labels.auto_approve_applies("orchestrator") is True
assert labels.auto_deploy_applies("orchestrator") is True
# Non-self repo with empty CSV -> not in scope.
assert labels.auto_approve_applies("enduro-trails") is False
assert labels.auto_deploy_applies("enduro-trails") is False
def test_tc05_csv_membership(monkeypatch):
monkeypatch.setattr(cfg.settings, "auto_label_repos", "enduro-trails, foo", raising=False)
assert labels.auto_approve_applies("enduro-trails") is True
assert labels.auto_deploy_applies("foo") is True
# orchestrator is NOT in the explicit CSV -> out of scope.
assert labels.auto_approve_applies("orchestrator") is False
# --- TC-06: kill-switch -> applies False, no network -----------------------
def test_tc06_killswitch_applies_false(monkeypatch):
monkeypatch.setattr(cfg.settings, "auto_label_enabled", False, raising=False)
assert labels.auto_approve_applies("orchestrator") is False
assert labels.auto_deploy_applies("orchestrator") is False
def test_tc06_killswitch_gate_makes_no_network(monkeypatch):
"""The gate idiom `applies(repo) and has_label(...)` short-circuits before any
network call when the kill-switch is off (AC-8)."""
monkeypatch.setattr(cfg.settings, "auto_label_enabled", False, raising=False)
called = {"fetch": 0}
def spy(*a, **k):
called["fetch"] += 1
return ["uuid-A"]
monkeypatch.setattr(plane_sync, "fetch_issue_labels", spy)
repo = "orchestrator"
fired = labels.auto_approve_applies(repo) and labels.has_label("ORCH-1", "autoApprove")
assert fired is False
assert called["fetch"] == 0 # has_label never reached -> zero network
def test_snapshot_never_raises():
snap = labels.snapshot()
assert set(snap) >= {"enabled", "approve_label", "deploy_label", "repos"}