Files
orchestrator/src/labels.py
claude-bot 2fe44d5747
All checks were successful
CI / test (push) Successful in 30s
CI / test (pull_request) Successful in 30s
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:20:32 +03:00

134 lines
5.9 KiB
Python

"""ORCH-089: auto-mode by Plane labels — autoApprove + autoDeploy (pure logic).
Leaf module — pure, unit-testable logic over the config flags + the Plane label
helpers in ``plane_sync``. Mirrors the leaf pattern of ``src/serial_gate.py`` /
``src/self_deploy.py``: imports only ``config`` (and lazily ``plane_sync`` /
``qg.checks`` / ``projects``), never ``stage_engine`` / ``launcher``.
What it decides (ADR-001 D1):
* Whether the auto-mode is in scope for a repo (``auto_approve_applies`` /
``auto_deploy_applies``) — a LOCAL, network-free check evaluated FIRST.
* Whether a given Plane label is present on an issue (``has_label``) — the only
network call, made ONLY after ``applies()`` is True, so a disabled kill-switch
costs zero network and yields zero regression (AC-8).
never-raise contract (BR-6/AC-6, fail-safe to the MANUAL gate): every public
function degrades to "no auto" on ANY error / ambiguity / Plane unavailability.
There is NO fail-open here — the conservative default is always "no auto"
(human gate stays), so an error can never auto-pass a gate.
"""
from __future__ import annotations
import logging
from .config import settings
logger = logging.getLogger("orchestrator.labels")
# ---------------------------------------------------------------------------
# Scope / kill-switch (mirrors self_deploy_applies / serial_gate_applies)
# ---------------------------------------------------------------------------
def _auto_label_applies(repo: str) -> bool:
"""Shared scope check for both auto-modes (ADR-001 D5).
* ``auto_label_enabled=False`` -> always False (kill-switch; both gates 1:1
as before ORCH-089, and — crucially — ``has_label`` is never consulted, so
no new network call on the gate, AC-8).
* ``auto_label_repos`` (CSV) non-empty -> real only for the listed repos.
* empty CSV -> self-hosting only (``orchestrator``) — the safe default
(the autoDeploy insertion lives in Phase A, which only exists for the
self-hosting repo).
Never raises -> False on error (degrade to "no auto" = manual gate).
"""
try:
if not getattr(settings, "auto_label_enabled", False):
return False
raw = (getattr(settings, "auto_label_repos", "") or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (avoids importing qg at load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("_auto_label_applies error for %s: %s", repo, e)
return False
def auto_approve_applies(repo: str) -> bool:
"""Whether the autoApprove (BRD gate) auto-mode is in scope for ``repo``."""
return _auto_label_applies(repo)
def auto_deploy_applies(repo: str) -> bool:
"""Whether the autoDeploy (prod-deploy gate) auto-mode is in scope for ``repo``."""
return _auto_label_applies(repo)
# ---------------------------------------------------------------------------
# Label presence (the ONLY network call; ADR-001 D1)
# ---------------------------------------------------------------------------
def has_label(work_item_id: str, label_name: str, project_id: str | None = None) -> bool:
"""True iff the issue carries a label whose name == ``label_name`` (normalized).
Resolution (all inside one ``try/except -> False``):
1. ``plane_sync.fetch_issue_labels`` — the issue's label uuids (None on error
-> False);
2. ``plane_sync.get_project_labels`` — {normalized_name -> uuid} project map
(TTL-cached);
3. normalize the sought name and look it up in the project map;
4. no match, OR an ambiguous name (the project map maps it to the
``__AMBIGUOUS__`` sentinel) -> False (fail-safe);
5. ``return target_uuid in set(labels)``.
Any error / unavailability / ambiguity -> **False** (never auto on doubt).
"""
if not label_name:
return False
try:
from . import plane_sync
labels = plane_sync.fetch_issue_labels(work_item_id, project_id)
if labels is None:
# Could not read the issue's labels -> fail-safe to manual.
return False
if not labels:
return False
name_map = plane_sync.get_project_labels(
plane_sync._resolve_project_id(work_item_id, project_id)
)
if not name_map:
return False
normalized = plane_sync._normalize_label(label_name)
target_uuid = name_map.get(normalized)
if not target_uuid or target_uuid == "__AMBIGUOUS__":
return False
return target_uuid in set(labels)
except Exception as e: # noqa: BLE001 - never-raise -> no auto
logger.warning(
"has_label error for %s/%s -> fail-safe (no auto): %s",
work_item_id, label_name, e,
)
return False
# ---------------------------------------------------------------------------
# Observability snapshot for GET /queue (ADR-001 D7)
# ---------------------------------------------------------------------------
def snapshot() -> dict:
"""Read-only auto-label summary for GET /queue (additive block). never-raise."""
try:
enabled = bool(getattr(settings, "auto_label_enabled", False))
except Exception: # noqa: BLE001
enabled = False
try:
return {
"enabled": enabled,
"approve_label": getattr(settings, "auto_approve_label", ""),
"deploy_label": getattr(settings, "auto_deploy_label", ""),
"repos": getattr(settings, "auto_label_repos", "") or "",
}
except Exception as e: # noqa: BLE001 - never-raise -> minimal dict
logger.warning("labels snapshot error: %s", e)
return {"enabled": enabled, "approve_label": "", "deploy_label": "", "repos": ""}