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>
This commit is contained in:
2026-06-09 12:20:32 +03:00
committed by orchestrator-deployer
parent f7488e9536
commit a6d0ba51c0
15 changed files with 1453 additions and 1 deletions

View File

@@ -487,6 +487,37 @@ class Settings(BaseSettings):
# *_repos, since auto-create is semantically inseparable from merge-verify.
merge_verify_autocreate_pr_enabled: bool = True
# ORCH-089: auto-mode by Plane labels — autoApprove (BRD gate) + autoDeploy
# (prod-deploy gate). Two HUMAN gates of the pipeline (analysis: wait for a
# manual Approved; deploy Phase A: wait for a manual Confirm Deploy) are the
# only blockers of an autonomous batch run (epic ORCH-088). ORCH-089 lifts ONLY
# those two human decisions — selectively (a Plane label on the issue),
# declaratively, reversibly, WITHOUT touching a single technical check. Additive
# leaf (src/labels.py, never-raise) + two point insertions + flags;
# STAGE_TRANSITIONS / QG_CHECKS / check_* / DB schema are NOT touched. See
# docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md.
# auto_label_enabled -> global kill-switch for BOTH auto-modes (env
# ORCH_AUTO_LABEL_ENABLED). False -> strictly the prior
# behaviour (both gates manual), AND no new network call
# on the gates (applies() returns False first, before
# has_label is consulted) — zero regression (AC-8).
# auto_approve_label -> Plane label name for the BRD gate (env
# ORCH_AUTO_APPROVE_LABEL).
# auto_deploy_label -> Plane label name for the deploy gate (env
# ORCH_AUTO_DEPLOY_LABEL).
# auto_label_repos -> CSV scope (env ORCH_AUTO_LABEL_REPOS). Empty ->
# self-hosting only (orchestrator), the safe default
# (the autoDeploy insertion lives in Phase A, which only
# exists for the self-hosting repo). Non-empty -> only
# the listed repos.
# auto_label_states_ttl_s -> TTL (seconds) of the per-project label-map cache
# (mirrors plane_states_ttl_s); 0 -> lifetime cache.
auto_label_enabled: bool = True
auto_approve_label: str = "autoApprove"
auto_deploy_label: str = "autoDeploy"
auto_label_repos: str = ""
auto_label_states_ttl_s: int = 300
# Telegram notifications
telegram_bot_token: str = ""
telegram_chat_id: str = ""

133
src/labels.py Normal file
View File

@@ -0,0 +1,133 @@
"""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": ""}

View File

@@ -150,6 +150,7 @@ async def queue():
from . import merge_gate
from . import task_deps
from . import serial_gate
from . import labels
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
@@ -165,6 +166,9 @@ async def queue():
# ORCH-088 (D9 / AC-10): per-repo serial-gate observability (read-only) —
# active task, queued/waiting analyst-jobs, freeze state. Additive block.
"serial_gate": serial_gate.snapshot(),
# ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch,
# label names, scope. Additive block.
"auto_labels": labels.snapshot(),
"recent": recent_jobs(10),
}

View File

@@ -326,6 +326,160 @@ def reload_project_states(project_id: str = None) -> None:
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
# ---------------------------------------------------------------------------
# ORCH-089: label reading (auto-mode by Plane labels) + Approved setter.
#
# Source of truth for an issue's labels is the Plane API, NOT the webhook payload
# (both auto-mode insertion points are launcher-path events where the payload is
# absent; src/webhooks/plane.py does not carry `labels`). All three helpers honour
# a never-raise contract: a failure degrades to "no label" / "no-op", so the
# auto-mode falls back to the manual gate (fail-safe, BR-6/AC-6).
# ---------------------------------------------------------------------------
# Per-project label-map cache (mirrors _STATES_CACHE / ORCH-068 TTL self-heal).
# Each entry: {"map": {normalized_name -> uuid}, "ts": monotonic timestamp}.
_LABELS_CACHE: dict[str, dict] = {}
def _normalize_label(name: str) -> str:
"""Normalize a label name for matching (case/whitespace-insensitive)."""
return (name or "").strip().casefold()
def _labels_record_fresh(record: dict) -> bool:
"""ORCH-089: is a label-map cache record still within its TTL?
``auto_label_states_ttl_s <= 0`` disables the TTL (lifetime cache, escape
hatch mirroring ``_cache_record_fresh`` / ``plane_states_ttl_s``).
"""
try:
ttl = settings.auto_label_states_ttl_s
except Exception: # noqa: BLE001
ttl = 0
if ttl <= 0:
return True
ts = record.get("ts", 0.0)
return (time.monotonic() - ts) <= ttl
def reload_project_labels(project_id: str = None) -> None:
"""ORCH-089: clear the per-project label-map cache (tests / config reload)."""
global _LABELS_CACHE
if project_id is None:
_LABELS_CACHE = {}
else:
_LABELS_CACHE.pop(project_id, None)
def get_project_labels(project_id: str) -> dict[str, str]:
"""ORCH-089: resolve {normalized_label_name -> uuid} for a Plane project.
Source of truth: GET /projects/<pid>/labels/. Cached per project_id with a
TTL (``auto_label_states_ttl_s``, default 300s) mirroring
``get_project_states`` so we do not hit the API on every gate. On a transient
API failure a stale-but-correct cached map is served (safer-than-empty); with
nothing cached -> ``{}`` (caller resolves to "no label" -> manual gate).
Ambiguity guard (D1.4): if two distinct project labels normalise to the SAME
name, that name is mapped to a sentinel so ``has_label`` treats it as "no
match" (fail-safe) instead of silently picking one uuid. never-raise -> ``{}``.
"""
if not project_id:
return {}
cached = _LABELS_CACHE.get(project_id)
if cached is not None and _labels_record_fresh(cached):
return cached["map"]
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/labels/"
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp.raise_for_status()
body = resp.json()
items = body.get("results", body) if isinstance(body, dict) else body
if not isinstance(items, list):
raise ValueError(f"unexpected labels response shape: {type(items)}")
name_map: dict[str, str] = {}
ambiguous: set[str] = set()
for item in items:
uid = item.get("id", "")
norm = _normalize_label(item.get("name", ""))
if not (uid and norm):
continue
if norm in name_map and name_map[norm] != uid:
# Two distinct labels collide on the normalized name -> ambiguous.
ambiguous.add(norm)
name_map[norm] = uid
for norm in ambiguous:
# AMBIGUOUS sentinel: never equals a real issue-label uuid, so
# has_label's membership test is False -> fail-safe to the manual gate.
name_map[norm] = "__AMBIGUOUS__"
logger.warning(
"get_project_labels: ambiguous label name %r in project %s "
"-> treated as no-match (fail-safe)", norm, project_id[:8],
)
_LABELS_CACHE[project_id] = {"map": name_map, "ts": time.monotonic()}
logger.debug(
"get_project_labels: cached %d labels for project %s...",
len(name_map), project_id[:8],
)
return name_map
except Exception as e: # noqa: BLE001 - never-raise
if cached is not None:
logger.warning(
"get_project_labels: API refresh failed for project %s..., "
"serving stale cached map. Error: %s", project_id[:8], e,
)
return cached["map"]
logger.warning(
"get_project_labels: API failed for project %s..., no cache -> {}. "
"Error: %s", project_id[:8], e,
)
return {}
def fetch_issue_labels(work_item_id: str, project_id: str = None) -> list[str] | None:
"""ORCH-089: GET the issue and return its ``labels`` (a list of label uuids).
Returns ``None`` on any error / issue-not-found (DISTINCT from ``[]`` = "the
issue has no labels") so the caller can distinguish "could not read" (fail-safe
to manual) from "definitely no labels". never-raise.
"""
project_id = _resolve_project_id(work_item_id, project_id)
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.debug("fetch_issue_labels: issue not found for %s", work_item_id)
return None
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp.raise_for_status()
labels = resp.json().get("labels", [])
if not isinstance(labels, list):
return None
return [str(x) for x in labels]
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("fetch_issue_labels failed for %s: %s", work_item_id, e)
return None
def set_issue_approved(work_item_id: str, project_id: str = None):
"""ORCH-089: set issue to 'Approved' — indication of an auto-approved BRD.
1:1 mirror of ``set_issue_in_review``: resolve the per-project Approved UUID
(``get_project_states(pid)["approved"]`` — the key already exists in
``_DEFAULT_STATES`` / ``_PLANE_NAME_TO_KEY``) and PATCH the issue. never-raise
(via ``_set_issue_state_direct``). The status is transient — the immediately
following advance to ``architecture`` overrides it; durable transparency is
carried by the log + Telegram + Plane comment (AC-7).
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["approved"]
_set_issue_state_direct(work_item_id, state_id, project_id)
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress

View File

@@ -39,6 +39,7 @@ from .qg.checks import QG_CHECKS
from . import merge_gate
from . import self_deploy
from . import post_deploy
from . import labels
from .notifications import (
notify_stage_change,
notify_qg_failure,
@@ -59,6 +60,7 @@ from .plane_sync import (
set_issue_awaiting_deploy,
set_issue_deploying,
set_issue_monitoring,
set_issue_approved,
)
from .config import settings
@@ -596,6 +598,47 @@ def _handle_analysis_approved_flow(
logger.info(
f"Task {task_id}: analyst finished, requested Approved status in Plane"
)
# --- ORCH-089 autoApprove: auto-pass the BRD human gate by label --------
# After In Review + the analyst comment + the approve-request (kept for the
# BRD-review clock, transparency and symmetry with the manual path), if the
# issue carries the autoApprove label AND the repo is in scope, auto-advance
# via the SAME path a human Approved takes — never duplicating the
# transition logic. applies() (local, network-free) is checked FIRST so a
# disabled kill-switch / out-of-scope repo costs zero network (AC-8); any
# error / no-label -> fall through to the prior behaviour (return, wait for
# a human, AC-4/AC-6).
if labels.auto_approve_applies(repo) and labels.has_label(
work_item_id, settings.auto_approve_label
):
set_issue_approved(work_item_id) # indication (AC-1), transient
logger.info(
f"Task {task_id}: label {settings.auto_approve_label} -> "
f"BRD auto-approved (analysis -> architecture)"
)
plane_add_comment(
work_item_id,
f"✅ BRD авто-подтверждён (лейбл {settings.auto_approve_label}). "
"Переход на architecture без ручного Approved.",
author="analyst",
)
send_telegram(
f"{link_for(work_item_id)}: BRD авто-подтверждён "
f"(лейбл {settings.auto_approve_label})."
)
# Same advance the human Approved webhook uses: finished_agent=None ->
# check_analysis_approved approved-via-status -> advance analysis ->
# architecture + mark_brd_review_ended (clock) + standard post-effects.
# Re-entrancy is safe: the nested call passes finished_agent=None, so it
# does NOT re-enter this analyst branch (which requires agent=='analyst').
auto = advance_stage(
task_id, current_stage, repo, work_item_id, branch, finished_agent=None
)
result.advanced = auto.advanced
result.to_stage = auto.to_stage
result.enqueued_agent = auto.enqueued_agent
result.enqueued_job_id = auto.enqueued_job_id
result.note = "auto-approved-via-label"
return
questions_path = os.path.join(
@@ -1179,6 +1222,40 @@ def _handle_self_deploy_phase_a(
# (e.g. after a crash/manual intervention), so `initiated`/`result` from an
# earlier attempt can never leak into this one.
self_deploy.clear_state(repo, work_item_id)
# --- ORCH-089 autoDeploy: auto-confirm the prod-deploy human gate by label --
# After advancing onto `deploy` + wiping stale markers and BEFORE the ask-human
# block, if the issue carries the autoDeploy label AND the repo is in scope,
# initiate Phase B via the SAME path a human Confirm Deploy takes. Only the
# indicative human steps are skipped (APPROVE_REQUESTED marker +
# set_issue_awaiting_deploy + the "flip to Confirm Deploy" comment/Telegram) —
# status Deploying is set by Phase B itself. BR-5/AC-5 hold STRUCTURALLY: Phase A
# is reached ONLY after the green edge sub-gates (security -> merge-gate ->
# image-freshness -> staging), so autoDeploy cannot deploy a broken build.
# Idempotency is the existing INITIATED marker inside _handle_self_deploy_phase_b.
# applies() FIRST (network-free); any error / no-label -> the prior Phase A
# ask-human flow (AC-4/AC-6).
if labels.auto_deploy_applies(repo) and labels.has_label(
work_item_id, settings.auto_deploy_label
):
logger.info(
f"Task {task_id}: label {settings.auto_deploy_label} -> "
f"prod deploy auto-confirmed (Phase B without manual Confirm Deploy)"
)
if work_item_id:
plane_add_comment(
work_item_id,
f"🚀 Прод-деплой авто-подтверждён (лейбл {settings.auto_deploy_label}). "
"Запуск Phase B без ручного «Confirm Deploy».",
author="deployer",
)
send_telegram(
f"🚀 {link_for(work_item_id)}: прод-деплой авто-подтверждён "
f"(лейбл {settings.auto_deploy_label})."
)
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
return
self_deploy.write_marker(
repo, work_item_id, self_deploy.APPROVE_REQUESTED, content=str(time.time())
)