Files
cleveragents-core/tools/controller/master/label_gate.py
T
drew 72c272504c feat(controller): Phase 1k — controller-managed opt-in label gate
Migration safety mechanism: the controller only manages PRs and
issues carrying a configurable opt-in label (default
``controller-managed``). Operators opt PRs in for parallel-run
trials, can pause management mid-flight by removing the label, and
gradually roll out without exposing the controller to PRs that
human reviewers are actively driving.

Components:
- tools/controller/master/label_gate.py — single source of truth for
  the configured label name + pure predicates/filters over Forgejo
  PR/issue dicts.
  - ``opt_in_label_name()`` reads ``CONTROLLER_OPT_IN_LABEL`` env
    (default 'controller-managed'); empty/whitespace falls back.
  - ``has_opt_in_label(entity, name)`` defensively handles every
    degenerate shape (non-dict entity, non-list labels, non-dict
    label entries, missing name field).
  - ``filter_by_opt_in_label`` / ``count_filtered`` for callers.

Wired through:
- discovery.run_discovery + backfill.run_startup_backfill +
  reconciliation.run_reconciliation_tick each accept
  ``opt_in_label`` and ``require_opt_in_label`` kwargs.
- Function defaults are ``require_opt_in_label=False`` for API
  back-compat (existing 30+ discovery/backfill/recon tests work
  without changes).
- __main__.py defaults to ``--no-opt-in-label`` OFF (gate ENABLED in
  production); add ``--no-opt-in-label`` to bypass.
- DiscoveryReport gains a ``label_filtered_out`` counter.

Reconciliation behavior:
- When opt_in_label is configured AND the Forgejo response carries a
  ``labels`` field AND the opt-in label is NOT present, the workflow
  transitions to ABANDONED with reason ``opt-in-label-removed`` +
  emits a controller_events 'reconciliation' row.
- Partial Forgejo responses (no ``labels`` field) skip the label
  check — never ABANDON on incomplete data.

Master loop extension:
- ``reconciliation_args`` now accepts an optional 5th element — a
  kwargs dict threaded through to ``run_reconciliation_tick``.
  __main__.py uses this to pass ``require_opt_in_label`` per the CLI
  flag. 4-tuple back-compat preserved.

Tests (+29 in test_label_gate.py, 0 regressions across 569 tests):
- Predicate edge cases (every degenerate shape returns False)
- Env-var resolution (default, override, empty, whitespace)
- filter/count helpers
- Discovery + backfill: kept/filtered counts, gate disabled,
  explicit label overrides env
- Reconciliation: label removed → ABANDONED, label present →
  no-op, partial response → no-op, gate disabled → bypass, event
  row records reason

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:45:22 -04:00

101 lines
3.5 KiB
Python

"""Controller-managed opt-in label gate (Phase 1k).
For migration safety, the controller only manages PRs and issues that
carry a configurable opt-in label (default: ``controller-managed``).
This lets operators:
- Opt one PR in for a parallel-run trial (Phase 2 migration mechanism).
- Roll out gradually without exposing the controller to PRs that
human reviewers are actively driving.
- Pause management of any PR mid-flight by removing the label.
The label name comes from ``CONTROLLER_OPT_IN_LABEL`` env var; default
is ``controller-managed``. Operators can override per-deployment.
What this module ships:
- ``opt_in_label_name()`` — single source of truth for the configured
label name.
- ``has_opt_in_label(entity_dict, label_name)`` — predicate over a
Forgejo PR/issue dict's ``labels`` field. Defensive about shape.
- ``filter_by_opt_in_label(entities, label_name)`` — list comprehension
wrapper for discovery/backfill.
Discovery (Phase 1d) + backfill (Phase 1f) are wired to use this; the
reconciliation tick (Phase 1g) checks for label removal post-discovery
and transitions opted-out workflows to ABANDONED with reason
``opt-in-label-removed``.
"""
from __future__ import annotations
import os
from typing import Any
DEFAULT_OPT_IN_LABEL = "controller-managed"
def opt_in_label_name() -> str:
"""Return the configured opt-in label name.
Reads ``CONTROLLER_OPT_IN_LABEL`` env var; falls back to
``controller-managed``. Empty or whitespace-only env value
falls back to the default (operators MUST set a non-empty name)."""
raw = os.environ.get("CONTROLLER_OPT_IN_LABEL", "").strip()
return raw or DEFAULT_OPT_IN_LABEL
def has_opt_in_label(entity: dict | None, label_name: str | None = None) -> bool:
"""True iff ``entity['labels']`` contains a label with the given
name (case-sensitive, since Forgejo label names are case-sensitive).
Defensive shape handling — returns False for any of:
- None entity
- non-dict entity
- missing or non-list ``labels`` field
- label entries that aren't dicts
- label entries with a missing or non-string ``name`` field
"""
name = label_name if label_name is not None else opt_in_label_name()
if not isinstance(entity, dict):
return False
labels = entity.get("labels")
if not isinstance(labels, list):
return False
for lbl in labels:
if not isinstance(lbl, dict):
continue
lbl_name = lbl.get("name")
if isinstance(lbl_name, str) and lbl_name == name:
return True
return False
def filter_by_opt_in_label(
entities: list[dict], label_name: str | None = None,
) -> list[dict]:
"""Return only the entities carrying the opt-in label.
Logs the skip count at INFO level via the caller's choice — this
is a pure function. Callers (discovery, backfill) log the
filtered-out total themselves."""
name = label_name if label_name is not None else opt_in_label_name()
return [e for e in entities if has_opt_in_label(e, name)]
def count_filtered(entities: list[dict], label_name: str | None = None) -> int:
"""Count entities filtered OUT by the opt-in label gate. Useful
for discovery/backfill reports."""
name = label_name if label_name is not None else opt_in_label_name()
if not isinstance(entities, list):
return 0
return sum(1 for e in entities if not has_opt_in_label(e, name))
__all__ = [
"DEFAULT_OPT_IN_LABEL",
"count_filtered",
"filter_by_opt_in_label",
"has_opt_in_label",
"opt_in_label_name",
]