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:
@@ -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
133
src/labels.py
Normal 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": ""}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user