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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user